synodic-client 0.0.1.dev61__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.dev61 → synodic_client-0.0.1.dev63}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/pyproject.toml +1 -1
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/bootstrap.py +2 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/qt.py +31 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/projects.py +7 -5
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/screen.py +15 -8
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/settings.py +9 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/spinner.py +46 -36
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tool_update_controller.py +34 -1
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/tray.py +6 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/theme.py +1 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/update_controller.py +21 -2
- {synodic_client-0.0.1.dev61 → 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.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_settings.py +43 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_controller.py +54 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/README.md +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/schema.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/install_workers.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/plugin_row.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/schema.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/schema.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_gather_packages.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_startup.py +0 -0
{synodic_client-0.0.1.dev61 → 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
|
|
@@ -305,6 +305,15 @@ class SettingsWindow(QMainWindow):
|
|
|
305
305
|
"""Re-enable the *Check for Updates* button after a check completes."""
|
|
306
306
|
self._check_updates_btn.setEnabled(True)
|
|
307
307
|
|
|
308
|
+
def update_config(self, config: ResolvedConfig) -> None:
|
|
309
|
+
"""Replace the internal config snapshot without emitting signals.
|
|
310
|
+
|
|
311
|
+
Called by controllers that persist timestamps so that the next
|
|
312
|
+
:meth:`sync_from_config` sees fresh data instead of the stale
|
|
313
|
+
snapshot captured at construction time.
|
|
314
|
+
"""
|
|
315
|
+
self._config = config
|
|
316
|
+
|
|
308
317
|
def set_last_checked(self, timestamp: str) -> None:
|
|
309
318
|
"""Update the *last updated* label from an ISO 8601 timestamp."""
|
|
310
319
|
relative = _format_relative_time(timestamp)
|
|
@@ -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
|
|
@@ -382,7 +407,12 @@ class ToolUpdateOrchestrator:
|
|
|
382
407
|
for pkg_name in result.updated_packages:
|
|
383
408
|
key = f'{plugin_name}/{pkg_name}' if plugin_name else pkg_name
|
|
384
409
|
existing[key] = now
|
|
385
|
-
update_user_config(last_tool_updates=existing)
|
|
410
|
+
resolved = update_user_config(last_tool_updates=existing)
|
|
411
|
+
# Refresh the config on the tools view so the next rebuild
|
|
412
|
+
# picks up the updated timestamps instead of stale data.
|
|
413
|
+
tools_view_ref = self._window.tools_view
|
|
414
|
+
if tools_view_ref is not None:
|
|
415
|
+
tools_view_ref._config = resolved
|
|
386
416
|
|
|
387
417
|
# Clear updating state on widgets
|
|
388
418
|
tools_view = self._window.tools_view
|
|
@@ -465,6 +495,9 @@ class ToolUpdateOrchestrator:
|
|
|
465
495
|
if coordinator is not None:
|
|
466
496
|
coordinator.invalidate()
|
|
467
497
|
self._on_package_remove_finished(result, plugin_name, package_name)
|
|
498
|
+
except asyncio.CancelledError:
|
|
499
|
+
logger.debug('Package removal cancelled (shutdown)')
|
|
500
|
+
raise
|
|
468
501
|
except Exception as exc:
|
|
469
502
|
logger.exception('Package removal failed')
|
|
470
503
|
tools_view = self._window.tools_view
|
{synodic_client-0.0.1.dev61 → 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.dev61 → 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
|
# ------------------------------------------------------------------
|
|
@@ -132,7 +142,8 @@ class UpdateController:
|
|
|
132
142
|
def _persist_check_timestamp(self) -> None:
|
|
133
143
|
"""Persist the current time as *last_client_update* and refresh the label."""
|
|
134
144
|
ts = datetime.now(UTC).isoformat()
|
|
135
|
-
update_user_config(last_client_update=ts)
|
|
145
|
+
resolved = update_user_config(last_client_update=ts)
|
|
146
|
+
self._settings_window.update_config(resolved)
|
|
136
147
|
self._settings_window.set_last_checked(ts)
|
|
137
148
|
|
|
138
149
|
def _report_error(self, message: str, *, silent: bool) -> None:
|
|
@@ -249,6 +260,9 @@ class UpdateController:
|
|
|
249
260
|
result = await check_for_update(self._client)
|
|
250
261
|
self._on_check_finished(result, silent=silent)
|
|
251
262
|
logger.info('[DIAG] Self-update check completed (silent=%s)', silent)
|
|
263
|
+
except asyncio.CancelledError:
|
|
264
|
+
logger.debug('Update check cancelled (shutdown)')
|
|
265
|
+
raise
|
|
252
266
|
except Exception as exc:
|
|
253
267
|
logger.exception('Update check failed')
|
|
254
268
|
self._on_check_error(str(exc), silent=silent)
|
|
@@ -310,6 +324,9 @@ class UpdateController:
|
|
|
310
324
|
on_progress=self._on_download_progress,
|
|
311
325
|
)
|
|
312
326
|
self._on_download_finished(success, version)
|
|
327
|
+
except asyncio.CancelledError:
|
|
328
|
+
logger.debug('Update download cancelled (shutdown)')
|
|
329
|
+
raise
|
|
313
330
|
except Exception as exc:
|
|
314
331
|
logger.exception('Update download failed')
|
|
315
332
|
self._on_download_error(str(exc))
|
|
@@ -329,7 +346,9 @@ class UpdateController:
|
|
|
329
346
|
|
|
330
347
|
# Persist the client-update timestamp (actual update downloaded)
|
|
331
348
|
ts = datetime.now(UTC).isoformat()
|
|
332
|
-
update_user_config(last_client_update=ts)
|
|
349
|
+
resolved = update_user_config(last_client_update=ts)
|
|
350
|
+
self._settings_window.update_config(resolved)
|
|
351
|
+
self._settings_window.set_last_checked(ts)
|
|
333
352
|
|
|
334
353
|
self._pending_version = version
|
|
335
354
|
|
{synodic_client-0.0.1.dev61 → 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]
|
|
@@ -404,3 +404,46 @@ class TestCheckForUpdatesButton:
|
|
|
404
404
|
window.set_checking()
|
|
405
405
|
assert window._check_updates_btn.isEnabled() is False
|
|
406
406
|
assert window._update_status_label.text() == 'Checking\u2026'
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ---------------------------------------------------------------------------
|
|
410
|
+
# update_config — silent config refresh
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class TestUpdateConfig:
|
|
415
|
+
"""Verify that update_config refreshes _config without emitting signals."""
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def test_update_config_replaces_internal_config() -> None:
|
|
419
|
+
"""update_config should replace _config with the new snapshot."""
|
|
420
|
+
window = _make_window(_make_config(last_client_update=None))
|
|
421
|
+
new_config = _make_config(last_client_update='2026-03-09T12:00:00+00:00')
|
|
422
|
+
|
|
423
|
+
window.update_config(new_config)
|
|
424
|
+
|
|
425
|
+
assert window._config is new_config
|
|
426
|
+
assert window._config.last_client_update == '2026-03-09T12:00:00+00:00'
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def test_update_config_does_not_emit_settings_changed() -> None:
|
|
430
|
+
"""update_config must NOT emit settings_changed to avoid circular reinit."""
|
|
431
|
+
window = _make_window()
|
|
432
|
+
signal_spy = MagicMock()
|
|
433
|
+
window.settings_changed.connect(signal_spy)
|
|
434
|
+
|
|
435
|
+
window.update_config(_make_config(update_channel='dev'))
|
|
436
|
+
|
|
437
|
+
signal_spy.assert_not_called()
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def test_sync_after_update_config_uses_new_timestamp() -> None:
|
|
441
|
+
"""sync_from_config after update_config should display the refreshed timestamp."""
|
|
442
|
+
window = _make_window(_make_config(last_client_update=None))
|
|
443
|
+
assert window._last_client_update_label.text() == ''
|
|
444
|
+
|
|
445
|
+
new_config = _make_config(last_client_update='2026-03-09T12:00:00+00:00')
|
|
446
|
+
window.update_config(new_config)
|
|
447
|
+
window.sync_from_config()
|
|
448
|
+
|
|
449
|
+
assert 'Last updated:' in window._last_client_update_label.text()
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_controller.py
RENAMED
|
@@ -414,3 +414,57 @@ class TestCheckError:
|
|
|
414
414
|
ctrl._on_check_error('timeout', silent=True)
|
|
415
415
|
|
|
416
416
|
assert banner.state.name == 'HIDDEN'
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---------------------------------------------------------------------------
|
|
420
|
+
# Timestamp sync — _persist_check_timestamp updates config
|
|
421
|
+
# ---------------------------------------------------------------------------
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class TestPersistCheckTimestamp:
|
|
425
|
+
"""Verify _persist_check_timestamp syncs the settings config."""
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def test_persist_updates_settings_config() -> None:
|
|
429
|
+
"""_persist_check_timestamp should call update_config on the settings window."""
|
|
430
|
+
ctrl, _app, _client, _banner, settings = _make_controller()
|
|
431
|
+
|
|
432
|
+
fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00')
|
|
433
|
+
with patch(
|
|
434
|
+
'synodic_client.application.update_controller.update_user_config',
|
|
435
|
+
return_value=fake_resolved,
|
|
436
|
+
):
|
|
437
|
+
ctrl._persist_check_timestamp()
|
|
438
|
+
|
|
439
|
+
settings.update_config.assert_called_once_with(fake_resolved)
|
|
440
|
+
settings.set_last_checked.assert_called_once()
|
|
441
|
+
|
|
442
|
+
@staticmethod
|
|
443
|
+
def test_on_check_finished_success_syncs_config() -> None:
|
|
444
|
+
"""A successful check should persist timestamp AND sync settings config."""
|
|
445
|
+
ctrl, _app, _client, _banner, settings = _make_controller()
|
|
446
|
+
result = UpdateInfo(available=False, current_version=Version('1.0.0'))
|
|
447
|
+
|
|
448
|
+
fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00')
|
|
449
|
+
with patch(
|
|
450
|
+
'synodic_client.application.update_controller.update_user_config',
|
|
451
|
+
return_value=fake_resolved,
|
|
452
|
+
):
|
|
453
|
+
ctrl._on_check_finished(result, silent=True)
|
|
454
|
+
|
|
455
|
+
settings.update_config.assert_called_once_with(fake_resolved)
|
|
456
|
+
|
|
457
|
+
@staticmethod
|
|
458
|
+
def test_download_finished_syncs_config_and_label() -> None:
|
|
459
|
+
"""_on_download_finished should sync config and update the label."""
|
|
460
|
+
ctrl, _app, _client, _banner, settings = _make_controller(auto_apply=False)
|
|
461
|
+
|
|
462
|
+
fake_resolved = _make_config(last_client_update='2026-03-09T00:00:00+00:00')
|
|
463
|
+
with patch(
|
|
464
|
+
'synodic_client.application.update_controller.update_user_config',
|
|
465
|
+
return_value=fake_resolved,
|
|
466
|
+
):
|
|
467
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
468
|
+
|
|
469
|
+
settings.update_config.assert_called_once_with(fake_resolved)
|
|
470
|
+
settings.set_last_checked.assert_called_once()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/data.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/init.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → 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.dev61 → 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
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_gather_packages.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → 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.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_preview_model.py
RENAMED
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_tray_window_show.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/qt/test_update_banner.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → 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.dev61 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev61 → synodic_client-0.0.1.dev63}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|