synodic-client 0.0.1.dev50__tar.gz → 0.0.1.dev52__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.dev50 → synodic_client-0.0.1.dev52}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/pyproject.toml +1 -1
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/qt.py +35 -2
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/install.py +17 -5
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/plugin_row.py +28 -12
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/screen.py +19 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/settings.py +17 -1
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/tool_update_controller.py +19 -3
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/tray.py +12 -2
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/theme.py +6 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/update_controller.py +34 -3
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_update_controller.py +93 -1
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/README.md +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/schema.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/install_workers.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/projects.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/schema.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/schema.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_gather_packages.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_settings.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/windows/test_startup.py +0 -0
|
@@ -5,14 +5,15 @@ import ctypes
|
|
|
5
5
|
import logging
|
|
6
6
|
import signal
|
|
7
7
|
import sys
|
|
8
|
+
import traceback
|
|
8
9
|
import types
|
|
9
10
|
from collections.abc import Callable
|
|
10
11
|
|
|
11
12
|
import qasync
|
|
12
13
|
from porringer.api import API
|
|
13
14
|
from porringer.schema import LocalConfiguration
|
|
14
|
-
from PySide6.QtCore import Qt, QTimer
|
|
15
|
-
from PySide6.QtWidgets import QApplication
|
|
15
|
+
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
|
|
16
|
+
from PySide6.QtWidgets import QApplication, QWidget
|
|
16
17
|
|
|
17
18
|
from synodic_client.application.icon import app_icon
|
|
18
19
|
from synodic_client.application.init import run_startup_preamble
|
|
@@ -90,6 +91,34 @@ def _install_exception_hook(logger: logging.Logger) -> None:
|
|
|
90
91
|
sys.excepthook = _exception_hook
|
|
91
92
|
|
|
92
93
|
|
|
94
|
+
class _TopLevelShowFilter(QObject):
|
|
95
|
+
"""[DIAG] Application-wide event filter that logs Show/WindowActivate on top-level widgets."""
|
|
96
|
+
|
|
97
|
+
_diag_logger = logging.getLogger('synodic_client.diag.window')
|
|
98
|
+
|
|
99
|
+
def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
|
|
100
|
+
if (
|
|
101
|
+
event.type() in {QEvent.Type.Show, QEvent.Type.WindowActivate}
|
|
102
|
+
and isinstance(obj, QWidget)
|
|
103
|
+
and obj.isWindow()
|
|
104
|
+
):
|
|
105
|
+
geo = obj.geometry()
|
|
106
|
+
stack = ''.join(traceback.format_stack(limit=12))
|
|
107
|
+
self._diag_logger.warning(
|
|
108
|
+
'[DIAG] Top-level window %s: class=%s title=%r geo=(%d,%d %dx%d) visible=%s\n%s',
|
|
109
|
+
event.type().name,
|
|
110
|
+
type(obj).__qualname__,
|
|
111
|
+
obj.windowTitle(),
|
|
112
|
+
geo.x(),
|
|
113
|
+
geo.y(),
|
|
114
|
+
geo.width(),
|
|
115
|
+
geo.height(),
|
|
116
|
+
obj.isVisible(),
|
|
117
|
+
stack,
|
|
118
|
+
)
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
93
122
|
def _init_app() -> QApplication:
|
|
94
123
|
"""Create and configure the ``QApplication``."""
|
|
95
124
|
# Set the App User Model ID so Windows uses our icon on the taskbar
|
|
@@ -104,6 +133,10 @@ def _init_app() -> QApplication:
|
|
|
104
133
|
app.setWindowIcon(app_icon())
|
|
105
134
|
app.setAttribute(Qt.ApplicationAttribute.AA_CompressHighFrequencyEvents)
|
|
106
135
|
|
|
136
|
+
# [DIAG] Install a global event filter to log every top-level window show.
|
|
137
|
+
diag_filter = _TopLevelShowFilter(app) # parented to app, prevented from GC
|
|
138
|
+
app.installEventFilter(diag_filter)
|
|
139
|
+
|
|
107
140
|
# Allow Ctrl+C in the terminal to terminate the application.
|
|
108
141
|
# Qt's event loop blocks Python's default SIGINT handling, so we
|
|
109
142
|
# install our own handler and use a short timer to let Python
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import asyncio
|
|
15
15
|
import logging
|
|
16
|
+
import traceback
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
@@ -27,6 +28,7 @@ from porringer.schema import (
|
|
|
27
28
|
SyncStrategy,
|
|
28
29
|
)
|
|
29
30
|
from PySide6.QtCore import Qt, QTimer, Signal
|
|
31
|
+
from PySide6.QtGui import QShowEvent
|
|
30
32
|
from PySide6.QtWidgets import (
|
|
31
33
|
QFileDialog,
|
|
32
34
|
QFrame,
|
|
@@ -890,6 +892,21 @@ class InstallPreviewWindow(QMainWindow):
|
|
|
890
892
|
|
|
891
893
|
self._init_ui()
|
|
892
894
|
|
|
895
|
+
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
|
896
|
+
"""[DIAG] Log every show event with a stack trace."""
|
|
897
|
+
geo = self.geometry()
|
|
898
|
+
stack = ''.join(traceback.format_stack(limit=10))
|
|
899
|
+
logger.warning(
|
|
900
|
+
'[DIAG] InstallPreviewWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
|
|
901
|
+
geo.x(),
|
|
902
|
+
geo.y(),
|
|
903
|
+
geo.width(),
|
|
904
|
+
geo.height(),
|
|
905
|
+
self.isVisible(),
|
|
906
|
+
stack,
|
|
907
|
+
)
|
|
908
|
+
super().showEvent(event)
|
|
909
|
+
|
|
893
910
|
def _init_ui(self) -> None:
|
|
894
911
|
"""Build the UI layout."""
|
|
895
912
|
central = QWidget()
|
|
@@ -952,11 +969,6 @@ class InstallPreviewWindow(QMainWindow):
|
|
|
952
969
|
|
|
953
970
|
# --- Lifecycle ---
|
|
954
971
|
|
|
955
|
-
def showEvent(self, event: Any) -> None:
|
|
956
|
-
"""Log when the window becomes visible."""
|
|
957
|
-
super().showEvent(event)
|
|
958
|
-
logger.info('Install preview window shown (visible=%s)', self.isVisible())
|
|
959
|
-
|
|
960
972
|
def closeEvent(self, event: Any) -> None:
|
|
961
973
|
"""Clean up the temp directory when the window is closed."""
|
|
962
974
|
logger.info('Install preview window closing')
|
|
@@ -37,8 +37,10 @@ from synodic_client.application.theme import (
|
|
|
37
37
|
PLUGIN_ROW_PROJECT_TAG_STYLE,
|
|
38
38
|
PLUGIN_ROW_PROJECT_TAG_TRANSITIVE_STYLE,
|
|
39
39
|
PLUGIN_ROW_REMOVE_STYLE,
|
|
40
|
+
PLUGIN_ROW_STATUS_MIN_WIDTH,
|
|
40
41
|
PLUGIN_ROW_STATUS_STYLE,
|
|
41
42
|
PLUGIN_ROW_STYLE,
|
|
43
|
+
PLUGIN_ROW_TIMESTAMP_MIN_WIDTH,
|
|
42
44
|
PLUGIN_ROW_TIMESTAMP_STYLE,
|
|
43
45
|
PLUGIN_ROW_TOGGLE_STYLE,
|
|
44
46
|
PLUGIN_ROW_UPDATE_STYLE,
|
|
@@ -341,7 +343,7 @@ class PluginRow(QFrame):
|
|
|
341
343
|
|
|
342
344
|
Controls are always created in the same order with fixed widths
|
|
343
345
|
so that columns align vertically across all rows. Hidden
|
|
344
|
-
controls
|
|
346
|
+
controls reserve space via ``retainSizeWhenHidden``.
|
|
345
347
|
"""
|
|
346
348
|
if data.show_toggle:
|
|
347
349
|
self._build_toggle(layout, data)
|
|
@@ -352,23 +354,25 @@ class PluginRow(QFrame):
|
|
|
352
354
|
# Inline auto-update status (e.g. "Up to date", "v1.2 available")
|
|
353
355
|
self._update_status_label = QLabel()
|
|
354
356
|
self._update_status_label.setStyleSheet(PLUGIN_ROW_STATUS_STYLE)
|
|
357
|
+
self._update_status_label.setMinimumWidth(PLUGIN_ROW_STATUS_MIN_WIDTH)
|
|
355
358
|
self._update_status_label.hide()
|
|
356
359
|
layout.addWidget(self._update_status_label)
|
|
360
|
+
self._retain_size(layout)
|
|
357
361
|
|
|
358
|
-
# Version
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
layout.addWidget(version_label)
|
|
362
|
+
# Version — always created so column width is reserved
|
|
363
|
+
version_label = QLabel(data.version)
|
|
364
|
+
version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE)
|
|
365
|
+
version_label.setMinimumWidth(PLUGIN_ROW_VERSION_MIN_WIDTH)
|
|
366
|
+
version_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
367
|
+
layout.addWidget(version_label)
|
|
365
368
|
|
|
366
|
-
# Timestamp
|
|
369
|
+
# Timestamp — always created so column width is reserved
|
|
370
|
+
self._timestamp_label = QLabel(_format_relative_time(data.last_updated) if data.last_updated else '')
|
|
371
|
+
self._timestamp_label.setStyleSheet(PLUGIN_ROW_TIMESTAMP_STYLE)
|
|
372
|
+
self._timestamp_label.setMinimumWidth(PLUGIN_ROW_TIMESTAMP_MIN_WIDTH)
|
|
367
373
|
if data.last_updated:
|
|
368
|
-
self._timestamp_label = QLabel(_format_relative_time(data.last_updated))
|
|
369
|
-
self._timestamp_label.setStyleSheet(PLUGIN_ROW_TIMESTAMP_STYLE)
|
|
370
374
|
self._timestamp_label.setToolTip(f'Last updated: {data.last_updated}')
|
|
371
|
-
|
|
375
|
+
layout.addWidget(self._timestamp_label)
|
|
372
376
|
|
|
373
377
|
# Transient inline error label (hidden by default)
|
|
374
378
|
self._error_label = QLabel()
|
|
@@ -409,6 +413,18 @@ class PluginRow(QFrame):
|
|
|
409
413
|
update_btn.setVisible(data.has_update)
|
|
410
414
|
self._update_btn = update_btn
|
|
411
415
|
layout.addWidget(update_btn)
|
|
416
|
+
self._retain_size(layout)
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _retain_size(layout: QHBoxLayout) -> None:
|
|
420
|
+
"""Mark the most recently added widget as size-retaining when hidden."""
|
|
421
|
+
item = layout.itemAt(layout.count() - 1)
|
|
422
|
+
if item is not None:
|
|
423
|
+
widget = item.widget()
|
|
424
|
+
if widget is not None:
|
|
425
|
+
policy = widget.sizePolicy()
|
|
426
|
+
policy.setRetainSizeWhenHidden(True)
|
|
427
|
+
widget.setSizePolicy(policy)
|
|
412
428
|
|
|
413
429
|
def _build_remove_button(self, layout: QHBoxLayout, data: PluginRowData) -> None:
|
|
414
430
|
"""Add the remove button — enabled only for global packages."""
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import logging
|
|
5
|
+
import traceback
|
|
5
6
|
from collections import OrderedDict
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
@@ -20,6 +21,7 @@ from porringer.schema import (
|
|
|
20
21
|
)
|
|
21
22
|
from porringer.schema.plugin import PluginKind
|
|
22
23
|
from PySide6.QtCore import Qt, QTimer, Signal
|
|
24
|
+
from PySide6.QtGui import QShowEvent
|
|
23
25
|
from PySide6.QtWidgets import (
|
|
24
26
|
QHBoxLayout,
|
|
25
27
|
QLineEdit,
|
|
@@ -197,6 +199,8 @@ class ToolsView(QWidget):
|
|
|
197
199
|
"""Schedule an asynchronous rebuild of the tool list."""
|
|
198
200
|
if self._refresh_in_progress:
|
|
199
201
|
return
|
|
202
|
+
caller = ''.join(traceback.format_stack(limit=4))
|
|
203
|
+
logger.info('[DIAG] ToolsView.refresh() called, parent_visible=%s\n%s', self.isVisible(), caller)
|
|
200
204
|
asyncio.create_task(self._async_refresh())
|
|
201
205
|
|
|
202
206
|
async def _async_refresh(self) -> None:
|
|
@@ -1104,6 +1108,21 @@ class MainWindow(QMainWindow):
|
|
|
1104
1108
|
# Update banner — always available, starts hidden.
|
|
1105
1109
|
self._update_banner = UpdateBanner(self)
|
|
1106
1110
|
|
|
1111
|
+
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
|
1112
|
+
"""[DIAG] Log every show event with a stack trace."""
|
|
1113
|
+
geo = self.geometry()
|
|
1114
|
+
stack = ''.join(traceback.format_stack(limit=10))
|
|
1115
|
+
logger.warning(
|
|
1116
|
+
'[DIAG] MainWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
|
|
1117
|
+
geo.x(),
|
|
1118
|
+
geo.y(),
|
|
1119
|
+
geo.width(),
|
|
1120
|
+
geo.height(),
|
|
1121
|
+
self.isVisible(),
|
|
1122
|
+
stack,
|
|
1123
|
+
)
|
|
1124
|
+
super().showEvent(event)
|
|
1125
|
+
|
|
1107
1126
|
@property
|
|
1108
1127
|
def porringer(self) -> API | None:
|
|
1109
1128
|
"""Return the porringer API instance, if available."""
|
|
@@ -7,11 +7,12 @@ Updates* button with inline status feedback.
|
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
9
|
import sys
|
|
10
|
+
import traceback
|
|
10
11
|
from collections.abc import Iterator
|
|
11
12
|
from contextlib import contextmanager
|
|
12
13
|
|
|
13
14
|
from PySide6.QtCore import Qt, QUrl, Signal
|
|
14
|
-
from PySide6.QtGui import QDesktopServices
|
|
15
|
+
from PySide6.QtGui import QDesktopServices, QShowEvent
|
|
15
16
|
from PySide6.QtWidgets import (
|
|
16
17
|
QCheckBox,
|
|
17
18
|
QComboBox,
|
|
@@ -53,6 +54,21 @@ class SettingsWindow(QMainWindow):
|
|
|
53
54
|
check_updates_requested = Signal()
|
|
54
55
|
"""Emitted when the user clicks the *Check for Updates* button."""
|
|
55
56
|
|
|
57
|
+
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
|
58
|
+
"""[DIAG] Log every show event with a stack trace."""
|
|
59
|
+
geo = self.geometry()
|
|
60
|
+
stack = ''.join(traceback.format_stack(limit=10))
|
|
61
|
+
logger.warning(
|
|
62
|
+
'[DIAG] SettingsWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
|
|
63
|
+
geo.x(),
|
|
64
|
+
geo.y(),
|
|
65
|
+
geo.width(),
|
|
66
|
+
geo.height(),
|
|
67
|
+
self.isVisible(),
|
|
68
|
+
stack,
|
|
69
|
+
)
|
|
70
|
+
super().showEvent(event)
|
|
71
|
+
|
|
56
72
|
def __init__(
|
|
57
73
|
self,
|
|
58
74
|
config: ResolvedConfig,
|
|
@@ -54,6 +54,7 @@ class ToolUpdateOrchestrator:
|
|
|
54
54
|
window: MainWindow,
|
|
55
55
|
config_resolver: Callable[[], ResolvedConfig],
|
|
56
56
|
tray: QSystemTrayIcon,
|
|
57
|
+
is_user_active: Callable[[], bool] | None = None,
|
|
57
58
|
) -> None:
|
|
58
59
|
"""Set up the controller.
|
|
59
60
|
|
|
@@ -61,10 +62,14 @@ class ToolUpdateOrchestrator:
|
|
|
61
62
|
window: The main application window.
|
|
62
63
|
config_resolver: Callable returning the current resolved config.
|
|
63
64
|
tray: System tray icon for notification messages.
|
|
65
|
+
is_user_active: Predicate returning ``True`` when the user
|
|
66
|
+
has a visible window. Periodic tool updates are
|
|
67
|
+
deferred while active.
|
|
64
68
|
"""
|
|
65
69
|
self._window = window
|
|
66
70
|
self._resolve_config = config_resolver
|
|
67
71
|
self._tray = tray
|
|
72
|
+
self._is_user_active = is_user_active or (lambda: False)
|
|
68
73
|
self._tool_task: asyncio.Task[None] | None = None
|
|
69
74
|
self._tool_update_timer: QTimer | None = None
|
|
70
75
|
|
|
@@ -108,10 +113,17 @@ class ToolUpdateOrchestrator:
|
|
|
108
113
|
self._tool_update_timer = self._restart_timer(
|
|
109
114
|
self._tool_update_timer,
|
|
110
115
|
config.tool_update_interval_minutes,
|
|
111
|
-
self.
|
|
116
|
+
self._on_periodic_tool_update,
|
|
112
117
|
'Automatic tool updating',
|
|
113
118
|
)
|
|
114
119
|
|
|
120
|
+
def _on_periodic_tool_update(self) -> None:
|
|
121
|
+
"""Timer callback — deferred when the user has a visible window."""
|
|
122
|
+
if self._is_user_active():
|
|
123
|
+
logger.debug('Periodic tool update deferred — user is active')
|
|
124
|
+
return
|
|
125
|
+
self.on_tool_update()
|
|
126
|
+
|
|
115
127
|
# -- ToolsView signal wiring --
|
|
116
128
|
|
|
117
129
|
def connect_tools_view(self, tools_view: ToolsView) -> None:
|
|
@@ -293,6 +305,12 @@ class ToolUpdateOrchestrator:
|
|
|
293
305
|
|
|
294
306
|
# Clear updating state on widgets
|
|
295
307
|
tools_view = self._window.tools_view
|
|
308
|
+
logger.info(
|
|
309
|
+
'[DIAG] _on_tool_update_finished: manual=%s, tools_view_exists=%s, window_visible=%s',
|
|
310
|
+
manual,
|
|
311
|
+
tools_view is not None,
|
|
312
|
+
self._window.isVisible(),
|
|
313
|
+
)
|
|
296
314
|
if tools_view is not None:
|
|
297
315
|
if updating_plugin is not None:
|
|
298
316
|
tools_view.set_plugin_updating(updating_plugin, False)
|
|
@@ -389,5 +407,3 @@ class ToolUpdateOrchestrator:
|
|
|
389
407
|
tools_view.set_package_removing(plugin_name, package_name, False)
|
|
390
408
|
tools_view._updates_checked = False
|
|
391
409
|
tools_view.refresh()
|
|
392
|
-
|
|
393
|
-
self._window.show()
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -72,15 +72,17 @@ class TrayScreen:
|
|
|
72
72
|
app,
|
|
73
73
|
client,
|
|
74
74
|
self._banner,
|
|
75
|
-
self._settings_window,
|
|
76
|
-
config,
|
|
75
|
+
settings_window=self._settings_window,
|
|
76
|
+
config=config,
|
|
77
77
|
)
|
|
78
|
+
self._update_controller.set_user_active_predicate(self._is_user_active)
|
|
78
79
|
|
|
79
80
|
# Tool update orchestrator - owns tool/package update lifecycle
|
|
80
81
|
self._tool_orchestrator = ToolUpdateOrchestrator(
|
|
81
82
|
window,
|
|
82
83
|
self._resolve_config,
|
|
83
84
|
self.tray,
|
|
85
|
+
is_user_active=self._is_user_active,
|
|
84
86
|
)
|
|
85
87
|
self._tool_orchestrator.restart_tool_update_timer()
|
|
86
88
|
|
|
@@ -128,6 +130,14 @@ class TrayScreen:
|
|
|
128
130
|
"""Show the settings window."""
|
|
129
131
|
self._settings_window.show()
|
|
130
132
|
|
|
133
|
+
def _is_user_active(self) -> bool:
|
|
134
|
+
"""Return ``True`` when the user has a visible window.
|
|
135
|
+
|
|
136
|
+
Used by the update controllers to defer automatic updates
|
|
137
|
+
while the user is actively interacting with the application.
|
|
138
|
+
"""
|
|
139
|
+
return self._window.isVisible() or self._settings_window.isVisible()
|
|
140
|
+
|
|
131
141
|
def _on_settings_changed(self, config: ResolvedConfig) -> None:
|
|
132
142
|
"""React to a change made in the settings window."""
|
|
133
143
|
self._config = config
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/theme.py
RENAMED
|
@@ -231,6 +231,12 @@ PLUGIN_ROW_UPDATE_WIDTH = 52
|
|
|
231
231
|
PLUGIN_ROW_VERSION_MIN_WIDTH = 60
|
|
232
232
|
"""Minimum width for the version label column."""
|
|
233
233
|
|
|
234
|
+
PLUGIN_ROW_STATUS_MIN_WIDTH = 90
|
|
235
|
+
"""Minimum width for the inline auto-update status label."""
|
|
236
|
+
|
|
237
|
+
PLUGIN_ROW_TIMESTAMP_MIN_WIDTH = 40
|
|
238
|
+
"""Minimum width for the relative timestamp label."""
|
|
239
|
+
|
|
234
240
|
PLUGIN_ROW_SPACING = 1
|
|
235
241
|
"""Pixels between individual tool/package rows."""
|
|
236
242
|
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import logging
|
|
13
|
+
from collections.abc import Callable
|
|
13
14
|
from datetime import UTC, datetime
|
|
14
15
|
from typing import TYPE_CHECKING
|
|
15
16
|
|
|
@@ -53,6 +54,9 @@ class UpdateController:
|
|
|
53
54
|
The ``SettingsWindow`` (receives status text + colour).
|
|
54
55
|
config:
|
|
55
56
|
Optional pre-resolved configuration. ``None`` resolves from disk.
|
|
57
|
+
is_user_active:
|
|
58
|
+
Predicate returning ``True`` when the user has a visible window.
|
|
59
|
+
Automatic checks and auto-apply are deferred while active.
|
|
56
60
|
"""
|
|
57
61
|
|
|
58
62
|
def __init__(
|
|
@@ -60,6 +64,7 @@ class UpdateController:
|
|
|
60
64
|
app: QApplication,
|
|
61
65
|
client: Client,
|
|
62
66
|
banner: UpdateBanner,
|
|
67
|
+
*,
|
|
63
68
|
settings_window: SettingsWindow,
|
|
64
69
|
config: ResolvedConfig | None = None,
|
|
65
70
|
) -> None:
|
|
@@ -77,6 +82,7 @@ class UpdateController:
|
|
|
77
82
|
self._banner = banner
|
|
78
83
|
self._settings_window = settings_window
|
|
79
84
|
self._config = config
|
|
85
|
+
self._is_user_active: Callable[[], bool] = lambda: False
|
|
80
86
|
self._update_task: asyncio.Task[None] | None = None
|
|
81
87
|
|
|
82
88
|
# Derive auto-apply preference from config
|
|
@@ -94,6 +100,14 @@ class UpdateController:
|
|
|
94
100
|
# Wire settings check-updates button
|
|
95
101
|
self._settings_window.check_updates_requested.connect(self._on_manual_check)
|
|
96
102
|
|
|
103
|
+
def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
|
|
104
|
+
"""Set the predicate used to defer automatic checks when the user is active.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
predicate: Returns ``True`` when the user has a visible window.
|
|
108
|
+
"""
|
|
109
|
+
self._is_user_active = predicate
|
|
110
|
+
|
|
97
111
|
# ------------------------------------------------------------------
|
|
98
112
|
# Config helpers
|
|
99
113
|
# ------------------------------------------------------------------
|
|
@@ -104,6 +118,14 @@ class UpdateController:
|
|
|
104
118
|
return self._config
|
|
105
119
|
return resolve_config()
|
|
106
120
|
|
|
121
|
+
def _can_auto_apply(self) -> bool:
|
|
122
|
+
"""Return whether a downloaded update should be applied automatically.
|
|
123
|
+
|
|
124
|
+
Auto-apply is suppressed when the user has a visible window so
|
|
125
|
+
the application is never force-restarted during active use.
|
|
126
|
+
"""
|
|
127
|
+
return self._auto_apply and not self._is_user_active()
|
|
128
|
+
|
|
107
129
|
# ------------------------------------------------------------------
|
|
108
130
|
# Timer management
|
|
109
131
|
# ------------------------------------------------------------------
|
|
@@ -176,7 +198,14 @@ class UpdateController:
|
|
|
176
198
|
self._do_check(silent=False)
|
|
177
199
|
|
|
178
200
|
def _on_auto_check(self) -> None:
|
|
179
|
-
"""Handle automatic (periodic) check — silent.
|
|
201
|
+
"""Handle automatic (periodic) check — silent.
|
|
202
|
+
|
|
203
|
+
Skipped when the user has a visible window to avoid disruptive
|
|
204
|
+
downloads and auto-apply restarts. The next timer tick retries.
|
|
205
|
+
"""
|
|
206
|
+
if self._is_user_active():
|
|
207
|
+
logger.debug('Automatic update check deferred — user is active')
|
|
208
|
+
return
|
|
180
209
|
self._do_check(silent=True)
|
|
181
210
|
|
|
182
211
|
def _do_check(self, *, silent: bool) -> None:
|
|
@@ -193,9 +222,11 @@ class UpdateController:
|
|
|
193
222
|
|
|
194
223
|
async def _async_check(self, *, silent: bool) -> None:
|
|
195
224
|
"""Run the update check coroutine and route results."""
|
|
225
|
+
logger.info('[DIAG] Self-update check starting (silent=%s)', silent)
|
|
196
226
|
try:
|
|
197
227
|
result = await check_for_update(self._client)
|
|
198
228
|
self._on_check_finished(result, silent=silent)
|
|
229
|
+
logger.info('[DIAG] Self-update check completed (silent=%s)', silent)
|
|
199
230
|
except Exception as exc:
|
|
200
231
|
logger.exception('Update check failed')
|
|
201
232
|
self._on_check_error(str(exc), silent=silent)
|
|
@@ -277,7 +308,7 @@ class UpdateController:
|
|
|
277
308
|
# Persist the client update timestamp
|
|
278
309
|
update_user_config(last_client_update=datetime.now(UTC).isoformat())
|
|
279
310
|
|
|
280
|
-
if self.
|
|
311
|
+
if self._can_auto_apply():
|
|
281
312
|
# Silently apply and restart — no banner, no user interaction
|
|
282
313
|
logger.info('Auto-applying update v%s', version)
|
|
283
314
|
self._settings_window.set_update_status(
|
|
@@ -287,7 +318,7 @@ class UpdateController:
|
|
|
287
318
|
self._apply_update(silent=True)
|
|
288
319
|
return
|
|
289
320
|
|
|
290
|
-
# Manual mode — show ready banner and let user choose when to restart
|
|
321
|
+
# Manual mode (or user is active) — show ready banner and let user choose when to restart
|
|
291
322
|
self._banner.show_ready(version)
|
|
292
323
|
self._settings_window.set_update_status(
|
|
293
324
|
f'v{version} ready',
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_update_controller.py
RENAMED
|
@@ -49,6 +49,7 @@ def _make_controller(
|
|
|
49
49
|
*,
|
|
50
50
|
auto_apply: bool = True,
|
|
51
51
|
auto_update_interval_minutes: int = 0,
|
|
52
|
+
is_user_active: bool = False,
|
|
52
53
|
) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]:
|
|
53
54
|
"""Build an ``UpdateController`` with mocked collaborators.
|
|
54
55
|
|
|
@@ -69,7 +70,14 @@ def _make_controller(
|
|
|
69
70
|
mock_ucfg.return_value = MagicMock(
|
|
70
71
|
auto_update_interval_minutes=auto_update_interval_minutes,
|
|
71
72
|
)
|
|
72
|
-
controller = UpdateController(
|
|
73
|
+
controller = UpdateController(
|
|
74
|
+
app,
|
|
75
|
+
client,
|
|
76
|
+
banner,
|
|
77
|
+
settings_window=settings,
|
|
78
|
+
config=config,
|
|
79
|
+
)
|
|
80
|
+
controller.set_user_active_predicate(lambda: is_user_active)
|
|
73
81
|
|
|
74
82
|
return controller, app, client, banner, settings
|
|
75
83
|
|
|
@@ -199,6 +207,90 @@ class TestDownloadFinished:
|
|
|
199
207
|
settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE)
|
|
200
208
|
|
|
201
209
|
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# User-active gating
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class TestUserActiveGating:
|
|
216
|
+
"""Verify that automatic actions are deferred when the user is active."""
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def test_auto_check_skipped_when_user_active() -> None:
|
|
220
|
+
"""_on_auto_check should not call _do_check when user is active."""
|
|
221
|
+
ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
|
|
222
|
+
|
|
223
|
+
with patch.object(ctrl, '_do_check') as mock_check:
|
|
224
|
+
ctrl._on_auto_check()
|
|
225
|
+
|
|
226
|
+
mock_check.assert_not_called()
|
|
227
|
+
|
|
228
|
+
@staticmethod
|
|
229
|
+
def test_auto_check_proceeds_when_user_inactive() -> None:
|
|
230
|
+
"""_on_auto_check should call _do_check when user is NOT active."""
|
|
231
|
+
ctrl, _app, _client, banner, settings = _make_controller(is_user_active=False)
|
|
232
|
+
|
|
233
|
+
with patch.object(ctrl, '_do_check') as mock_check:
|
|
234
|
+
ctrl._on_auto_check()
|
|
235
|
+
|
|
236
|
+
mock_check.assert_called_once_with(silent=True)
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def test_manual_check_unaffected_by_active_user() -> None:
|
|
240
|
+
"""_on_manual_check should always call _do_check regardless of user activity."""
|
|
241
|
+
ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
|
|
242
|
+
|
|
243
|
+
with patch.object(ctrl, '_do_check') as mock_check:
|
|
244
|
+
ctrl._on_manual_check()
|
|
245
|
+
|
|
246
|
+
mock_check.assert_called_once_with(silent=False)
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def test_auto_apply_deferred_when_user_active() -> None:
|
|
250
|
+
"""When auto_apply=True but user is active, show READY banner instead of applying."""
|
|
251
|
+
ctrl, app, client, banner, settings = _make_controller(
|
|
252
|
+
auto_apply=True,
|
|
253
|
+
is_user_active=True,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
with patch.object(ctrl, '_apply_update') as mock_apply:
|
|
257
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
258
|
+
|
|
259
|
+
mock_apply.assert_not_called()
|
|
260
|
+
assert banner.state.name == 'READY'
|
|
261
|
+
|
|
262
|
+
@staticmethod
|
|
263
|
+
def test_auto_apply_proceeds_when_user_inactive() -> None:
|
|
264
|
+
"""When auto_apply=True and user is inactive, _apply_update is called."""
|
|
265
|
+
ctrl, app, client, banner, settings = _make_controller(
|
|
266
|
+
auto_apply=True,
|
|
267
|
+
is_user_active=False,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
with patch.object(ctrl, '_apply_update') as mock_apply:
|
|
271
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
272
|
+
|
|
273
|
+
mock_apply.assert_called_once_with(silent=True)
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def test_can_auto_apply_false_when_user_active() -> None:
|
|
277
|
+
"""_can_auto_apply should return False when auto_apply=True but user is active."""
|
|
278
|
+
ctrl, *_ = _make_controller(auto_apply=True, is_user_active=True)
|
|
279
|
+
assert ctrl._can_auto_apply() is False
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def test_can_auto_apply_false_when_disabled() -> None:
|
|
283
|
+
"""_can_auto_apply should return False when auto_apply=False."""
|
|
284
|
+
ctrl, *_ = _make_controller(auto_apply=False, is_user_active=False)
|
|
285
|
+
assert ctrl._can_auto_apply() is False
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def test_can_auto_apply_true_when_enabled_and_inactive() -> None:
|
|
289
|
+
"""_can_auto_apply should return True only when auto_apply=True and user is inactive."""
|
|
290
|
+
ctrl, *_ = _make_controller(auto_apply=True, is_user_active=False)
|
|
291
|
+
assert ctrl._can_auto_apply() is True
|
|
292
|
+
|
|
293
|
+
|
|
202
294
|
# ---------------------------------------------------------------------------
|
|
203
295
|
# Apply update
|
|
204
296
|
# ---------------------------------------------------------------------------
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/bootstrap.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/data.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/init.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/schema.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/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
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/synodic_client/application/workers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_gather_packages.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_install_preview.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_preview_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_tray_window_show.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/qt/test_update_banner.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/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.dev50 → synodic_client-0.0.1.dev52}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev50 → synodic_client-0.0.1.dev52}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|