synodic-client 0.0.1.dev51__tar.gz → 0.0.1.dev53__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.dev51 → synodic_client-0.0.1.dev53}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/pyproject.toml +1 -1
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/bootstrap.py +4 -3
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/qt.py +44 -5
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/install.py +17 -5
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/plugin_row.py +28 -12
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/screen.py +18 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/settings.py +46 -2
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tool_update_controller.py +19 -1
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tray.py +15 -2
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/theme.py +6 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/update_controller.py +34 -3
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/cli.py +5 -1
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/logging.py +33 -2
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/resolution.py +2 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/schema.py +5 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/updater.py +12 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_gather_packages.py +1 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_settings.py +1 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_controller.py +117 -1
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_cli.py +3 -3
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_config.py +1 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_resolution.py +1 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_updater.py +10 -2
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/README.md +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/schema.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/install_workers.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/projects.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/schema.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_startup.py +0 -0
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/bootstrap.py
RENAMED
|
@@ -20,11 +20,12 @@ from synodic_client.logging import configure_logging
|
|
|
20
20
|
from synodic_client.protocol import extract_uri_from_args
|
|
21
21
|
from synodic_client.updater import initialize_velopack
|
|
22
22
|
|
|
23
|
-
# Parse
|
|
23
|
+
# Parse flags early so logging uses the right filename and level.
|
|
24
24
|
_dev_mode = '--dev' in sys.argv[1:]
|
|
25
|
+
_debug = '--debug' in sys.argv[1:]
|
|
25
26
|
set_dev_mode(_dev_mode)
|
|
26
27
|
|
|
27
|
-
configure_logging()
|
|
28
|
+
configure_logging(debug=_debug)
|
|
28
29
|
initialize_velopack()
|
|
29
30
|
|
|
30
31
|
if not _dev_mode:
|
|
@@ -35,4 +36,4 @@ if not _dev_mode:
|
|
|
35
36
|
# Heavy imports happen here — PySide6, porringer, etc.
|
|
36
37
|
from synodic_client.application.qt import application
|
|
37
38
|
|
|
38
|
-
application(uri=extract_uri_from_args(), dev_mode=_dev_mode)
|
|
39
|
+
application(uri=extract_uri_from_args(), dev_mode=_dev_mode, debug=_debug)
|
|
@@ -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
|
|
@@ -23,7 +24,7 @@ from synodic_client.application.screen.tray import TrayScreen
|
|
|
23
24
|
from synodic_client.application.uri import parse_uri
|
|
24
25
|
from synodic_client.client import Client
|
|
25
26
|
from synodic_client.config import set_dev_mode
|
|
26
|
-
from synodic_client.logging import configure_logging
|
|
27
|
+
from synodic_client.logging import configure_logging, set_debug_level
|
|
27
28
|
from synodic_client.protocol import extract_uri_from_args
|
|
28
29
|
from synodic_client.resolution import (
|
|
29
30
|
ResolvedConfig,
|
|
@@ -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.debug(
|
|
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
|
|
@@ -116,7 +149,7 @@ def _init_app() -> QApplication:
|
|
|
116
149
|
return app
|
|
117
150
|
|
|
118
151
|
|
|
119
|
-
def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
|
|
152
|
+
def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool = False) -> None:
|
|
120
153
|
"""Application entry point.
|
|
121
154
|
|
|
122
155
|
Args:
|
|
@@ -126,13 +159,14 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
|
|
|
126
159
|
log files, or single-instance locks with the user-installed
|
|
127
160
|
application. Velopack initialisation and protocol
|
|
128
161
|
registration are skipped.
|
|
162
|
+
debug: When ``True``, enable DEBUG-level file logging.
|
|
129
163
|
"""
|
|
130
164
|
# Activate dev-mode namespacing before anything reads config paths.
|
|
131
165
|
set_dev_mode(dev_mode)
|
|
132
166
|
|
|
133
167
|
# Configure logging before Velopack so install/uninstall hooks and
|
|
134
168
|
# first-run diagnostics are captured in the log file.
|
|
135
|
-
configure_logging()
|
|
169
|
+
configure_logging(debug=debug)
|
|
136
170
|
logger = logging.getLogger('synodic_client')
|
|
137
171
|
_install_exception_hook(logger)
|
|
138
172
|
|
|
@@ -147,6 +181,11 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
|
|
|
147
181
|
|
|
148
182
|
client, porringer, config = _init_services(logger)
|
|
149
183
|
|
|
184
|
+
# Honour the persisted debug_logging preference unless the --debug
|
|
185
|
+
# flag already activated it.
|
|
186
|
+
if not debug and config.debug_logging:
|
|
187
|
+
set_debug_level(enabled=True)
|
|
188
|
+
|
|
150
189
|
app = _init_app()
|
|
151
190
|
|
|
152
191
|
loop = qasync.QEventLoop(app)
|
|
@@ -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,7 @@ class ToolsView(QWidget):
|
|
|
197
199
|
"""Schedule an asynchronous rebuild of the tool list."""
|
|
198
200
|
if self._refresh_in_progress:
|
|
199
201
|
return
|
|
202
|
+
logger.debug('ToolsView.refresh() called (visible=%s)', self.isVisible())
|
|
200
203
|
asyncio.create_task(self._async_refresh())
|
|
201
204
|
|
|
202
205
|
async def _async_refresh(self) -> None:
|
|
@@ -1104,6 +1107,21 @@ class MainWindow(QMainWindow):
|
|
|
1104
1107
|
# Update banner — always available, starts hidden.
|
|
1105
1108
|
self._update_banner = UpdateBanner(self)
|
|
1106
1109
|
|
|
1110
|
+
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
|
1111
|
+
"""[DIAG] Log every show event with a stack trace."""
|
|
1112
|
+
geo = self.geometry()
|
|
1113
|
+
stack = ''.join(traceback.format_stack(limit=10))
|
|
1114
|
+
logger.debug(
|
|
1115
|
+
'[DIAG] MainWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
|
|
1116
|
+
geo.x(),
|
|
1117
|
+
geo.y(),
|
|
1118
|
+
geo.width(),
|
|
1119
|
+
geo.height(),
|
|
1120
|
+
self.isVisible(),
|
|
1121
|
+
stack,
|
|
1122
|
+
)
|
|
1123
|
+
super().showEvent(event)
|
|
1124
|
+
|
|
1107
1125
|
@property
|
|
1108
1126
|
def porringer(self) -> API | None:
|
|
1109
1127
|
"""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,
|
|
@@ -31,7 +32,7 @@ from synodic_client.application.icon import app_icon
|
|
|
31
32
|
from synodic_client.application.screen import _format_relative_time
|
|
32
33
|
from synodic_client.application.screen.card import CardFrame
|
|
33
34
|
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
|
|
34
|
-
from synodic_client.logging import log_path
|
|
35
|
+
from synodic_client.logging import log_path, set_debug_level
|
|
35
36
|
from synodic_client.resolution import ResolvedConfig, update_user_config
|
|
36
37
|
from synodic_client.schema import GITHUB_REPO_URL
|
|
37
38
|
from synodic_client.startup import is_startup_registered, register_startup, remove_startup
|
|
@@ -53,6 +54,24 @@ class SettingsWindow(QMainWindow):
|
|
|
53
54
|
check_updates_requested = Signal()
|
|
54
55
|
"""Emitted when the user clicks the *Check for Updates* button."""
|
|
55
56
|
|
|
57
|
+
restart_requested = Signal()
|
|
58
|
+
"""Emitted when the user clicks the *Restart & Update* button."""
|
|
59
|
+
|
|
60
|
+
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
|
61
|
+
"""[DIAG] Log every show event with a stack trace."""
|
|
62
|
+
geo = self.geometry()
|
|
63
|
+
stack = ''.join(traceback.format_stack(limit=10))
|
|
64
|
+
logger.debug(
|
|
65
|
+
'[DIAG] SettingsWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
|
|
66
|
+
geo.x(),
|
|
67
|
+
geo.y(),
|
|
68
|
+
geo.width(),
|
|
69
|
+
geo.height(),
|
|
70
|
+
self.isVisible(),
|
|
71
|
+
stack,
|
|
72
|
+
)
|
|
73
|
+
super().showEvent(event)
|
|
74
|
+
|
|
56
75
|
def __init__(
|
|
57
76
|
self,
|
|
58
77
|
config: ResolvedConfig,
|
|
@@ -183,6 +202,12 @@ class SettingsWindow(QMainWindow):
|
|
|
183
202
|
self._update_status_label = QLabel('')
|
|
184
203
|
self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
185
204
|
row.addWidget(self._update_status_label)
|
|
205
|
+
|
|
206
|
+
self._restart_btn = QPushButton('Restart \u0026 Update')
|
|
207
|
+
self._restart_btn.clicked.connect(self.restart_requested.emit)
|
|
208
|
+
self._restart_btn.hide()
|
|
209
|
+
row.addWidget(self._restart_btn)
|
|
210
|
+
|
|
186
211
|
row.addStretch()
|
|
187
212
|
content.addLayout(row)
|
|
188
213
|
|
|
@@ -202,6 +227,12 @@ class SettingsWindow(QMainWindow):
|
|
|
202
227
|
def _build_advanced_section(self) -> CardFrame:
|
|
203
228
|
"""Construct the *Advanced* settings card."""
|
|
204
229
|
card = CardFrame('Advanced')
|
|
230
|
+
|
|
231
|
+
self._debug_logging_check = QCheckBox('Debug logging')
|
|
232
|
+
self._debug_logging_check.setToolTip('Write DEBUG-level messages to the log file')
|
|
233
|
+
self._debug_logging_check.toggled.connect(self._on_debug_logging_changed)
|
|
234
|
+
card.content_layout.addWidget(self._debug_logging_check)
|
|
235
|
+
|
|
205
236
|
row = QHBoxLayout()
|
|
206
237
|
open_log_btn = QPushButton('Open Log\u2026')
|
|
207
238
|
open_log_btn.clicked.connect(self._open_log)
|
|
@@ -238,6 +269,9 @@ class SettingsWindow(QMainWindow):
|
|
|
238
269
|
self._auto_apply_check.setChecked(config.auto_apply)
|
|
239
270
|
self._auto_start_check.setChecked(is_startup_registered())
|
|
240
271
|
|
|
272
|
+
# Debug logging
|
|
273
|
+
self._debug_logging_check.setChecked(config.debug_logging)
|
|
274
|
+
|
|
241
275
|
# Last client update timestamp
|
|
242
276
|
if config.last_client_update:
|
|
243
277
|
relative = _format_relative_time(config.last_client_update)
|
|
@@ -259,6 +293,7 @@ class SettingsWindow(QMainWindow):
|
|
|
259
293
|
def set_checking(self) -> None:
|
|
260
294
|
"""Enter the *checking* state — disable button and show status."""
|
|
261
295
|
self._check_updates_btn.setEnabled(False)
|
|
296
|
+
self._restart_btn.hide()
|
|
262
297
|
self._update_status_label.setText('Checking\u2026')
|
|
263
298
|
self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
|
|
264
299
|
|
|
@@ -266,6 +301,10 @@ class SettingsWindow(QMainWindow):
|
|
|
266
301
|
"""Re-enable the *Check for Updates* button after a check completes."""
|
|
267
302
|
self._check_updates_btn.setEnabled(True)
|
|
268
303
|
|
|
304
|
+
def show_restart_button(self) -> None:
|
|
305
|
+
"""Show the *Restart & Update* button."""
|
|
306
|
+
self._restart_btn.show()
|
|
307
|
+
|
|
269
308
|
def show(self) -> None:
|
|
270
309
|
"""Sync controls from config, then show the window."""
|
|
271
310
|
self.sync_from_config()
|
|
@@ -297,6 +336,7 @@ class SettingsWindow(QMainWindow):
|
|
|
297
336
|
self._detect_updates_check,
|
|
298
337
|
self._auto_apply_check,
|
|
299
338
|
self._auto_start_check,
|
|
339
|
+
self._debug_logging_check,
|
|
300
340
|
self._check_updates_btn,
|
|
301
341
|
)
|
|
302
342
|
for w in widgets:
|
|
@@ -346,6 +386,10 @@ class SettingsWindow(QMainWindow):
|
|
|
346
386
|
remove_startup()
|
|
347
387
|
self.settings_changed.emit(self._config)
|
|
348
388
|
|
|
389
|
+
def _on_debug_logging_changed(self, checked: bool) -> None:
|
|
390
|
+
set_debug_level(enabled=checked)
|
|
391
|
+
self._persist(debug_logging=checked)
|
|
392
|
+
|
|
349
393
|
@staticmethod
|
|
350
394
|
def _open_log() -> None:
|
|
351
395
|
"""Open the log file in the system's default editor."""
|
|
@@ -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)
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -5,6 +5,7 @@ import logging
|
|
|
5
5
|
from PySide6.QtGui import QAction
|
|
6
6
|
from PySide6.QtWidgets import (
|
|
7
7
|
QApplication,
|
|
8
|
+
QMainWindow,
|
|
8
9
|
QMenu,
|
|
9
10
|
QSystemTrayIcon,
|
|
10
11
|
)
|
|
@@ -72,15 +73,17 @@ class TrayScreen:
|
|
|
72
73
|
app,
|
|
73
74
|
client,
|
|
74
75
|
self._banner,
|
|
75
|
-
self._settings_window,
|
|
76
|
-
config,
|
|
76
|
+
settings_window=self._settings_window,
|
|
77
|
+
config=config,
|
|
77
78
|
)
|
|
79
|
+
self._update_controller.set_user_active_predicate(self._is_user_active)
|
|
78
80
|
|
|
79
81
|
# Tool update orchestrator - owns tool/package update lifecycle
|
|
80
82
|
self._tool_orchestrator = ToolUpdateOrchestrator(
|
|
81
83
|
window,
|
|
82
84
|
self._resolve_config,
|
|
83
85
|
self.tray,
|
|
86
|
+
is_user_active=self._is_user_active,
|
|
84
87
|
)
|
|
85
88
|
self._tool_orchestrator.restart_tool_update_timer()
|
|
86
89
|
|
|
@@ -128,6 +131,16 @@ class TrayScreen:
|
|
|
128
131
|
"""Show the settings window."""
|
|
129
132
|
self._settings_window.show()
|
|
130
133
|
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _is_user_active() -> bool:
|
|
136
|
+
"""Return ``True`` when the user has a visible application window.
|
|
137
|
+
|
|
138
|
+
Checks all top-level ``QMainWindow`` instances (main window,
|
|
139
|
+
settings, install previews) so that auto-apply is deferred
|
|
140
|
+
whenever *any* window is open.
|
|
141
|
+
"""
|
|
142
|
+
return any(w.isVisible() for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow))
|
|
143
|
+
|
|
131
144
|
def _on_settings_changed(self, config: ResolvedConfig) -> None:
|
|
132
145
|
"""React to a change made in the settings window."""
|
|
133
146
|
self._config = config
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/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
|
+
Auto-apply is deferred while active; checks still run normally.
|
|
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
|
|
@@ -93,6 +99,15 @@ class UpdateController:
|
|
|
93
99
|
|
|
94
100
|
# Wire settings check-updates button
|
|
95
101
|
self._settings_window.check_updates_requested.connect(self._on_manual_check)
|
|
102
|
+
self._settings_window.restart_requested.connect(self._apply_update)
|
|
103
|
+
|
|
104
|
+
def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
|
|
105
|
+
"""Set the predicate used to defer auto-apply when the user is active.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
predicate: Returns ``True`` when the user has a visible window.
|
|
109
|
+
"""
|
|
110
|
+
self._is_user_active = predicate
|
|
96
111
|
|
|
97
112
|
# ------------------------------------------------------------------
|
|
98
113
|
# Config helpers
|
|
@@ -104,6 +119,14 @@ class UpdateController:
|
|
|
104
119
|
return self._config
|
|
105
120
|
return resolve_config()
|
|
106
121
|
|
|
122
|
+
def _can_auto_apply(self) -> bool:
|
|
123
|
+
"""Return whether a downloaded update should be applied automatically.
|
|
124
|
+
|
|
125
|
+
Auto-apply is suppressed when the user has a visible window so
|
|
126
|
+
the application is never force-restarted during active use.
|
|
127
|
+
"""
|
|
128
|
+
return self._auto_apply and not self._is_user_active()
|
|
129
|
+
|
|
107
130
|
# ------------------------------------------------------------------
|
|
108
131
|
# Timer management
|
|
109
132
|
# ------------------------------------------------------------------
|
|
@@ -176,7 +199,12 @@ class UpdateController:
|
|
|
176
199
|
self._do_check(silent=False)
|
|
177
200
|
|
|
178
201
|
def _on_auto_check(self) -> None:
|
|
179
|
-
"""Handle automatic (periodic) check — silent.
|
|
202
|
+
"""Handle automatic (periodic) check — silent.
|
|
203
|
+
|
|
204
|
+
The check always runs so the settings window can show the
|
|
205
|
+
latest status and the *last updated* timestamp stays current.
|
|
206
|
+
Auto-apply is gated separately by :meth:`_can_auto_apply`.
|
|
207
|
+
"""
|
|
180
208
|
self._do_check(silent=True)
|
|
181
209
|
|
|
182
210
|
def _do_check(self, *, silent: bool) -> None:
|
|
@@ -193,9 +221,11 @@ class UpdateController:
|
|
|
193
221
|
|
|
194
222
|
async def _async_check(self, *, silent: bool) -> None:
|
|
195
223
|
"""Run the update check coroutine and route results."""
|
|
224
|
+
logger.info('[DIAG] Self-update check starting (silent=%s)', silent)
|
|
196
225
|
try:
|
|
197
226
|
result = await check_for_update(self._client)
|
|
198
227
|
self._on_check_finished(result, silent=silent)
|
|
228
|
+
logger.info('[DIAG] Self-update check completed (silent=%s)', silent)
|
|
199
229
|
except Exception as exc:
|
|
200
230
|
logger.exception('Update check failed')
|
|
201
231
|
self._on_check_error(str(exc), silent=silent)
|
|
@@ -277,7 +307,7 @@ class UpdateController:
|
|
|
277
307
|
# Persist the client update timestamp
|
|
278
308
|
update_user_config(last_client_update=datetime.now(UTC).isoformat())
|
|
279
309
|
|
|
280
|
-
if self.
|
|
310
|
+
if self._can_auto_apply():
|
|
281
311
|
# Silently apply and restart — no banner, no user interaction
|
|
282
312
|
logger.info('Auto-applying update v%s', version)
|
|
283
313
|
self._settings_window.set_update_status(
|
|
@@ -287,12 +317,13 @@ class UpdateController:
|
|
|
287
317
|
self._apply_update(silent=True)
|
|
288
318
|
return
|
|
289
319
|
|
|
290
|
-
# Manual mode — show ready banner and let user choose when to restart
|
|
320
|
+
# Manual mode (or user is active) — show ready banner and let user choose when to restart
|
|
291
321
|
self._banner.show_ready(version)
|
|
292
322
|
self._settings_window.set_update_status(
|
|
293
323
|
f'v{version} ready',
|
|
294
324
|
UPDATE_STATUS_UP_TO_DATE_STYLE,
|
|
295
325
|
)
|
|
326
|
+
self._settings_window.show_restart_button()
|
|
296
327
|
|
|
297
328
|
def _on_download_error(self, error: str) -> None:
|
|
298
329
|
"""Handle download error — show error banner."""
|
|
@@ -35,8 +35,12 @@ def main(
|
|
|
35
35
|
bool,
|
|
36
36
|
typer.Option('--dev', help='Run in dev mode with isolated config, logs, and instance lock.'),
|
|
37
37
|
] = False,
|
|
38
|
+
debug: Annotated[
|
|
39
|
+
bool,
|
|
40
|
+
typer.Option('--debug', help='Enable DEBUG-level file logging for this session.'),
|
|
41
|
+
] = False,
|
|
38
42
|
) -> None:
|
|
39
43
|
"""Launch the Synodic Client GUI application."""
|
|
40
44
|
from synodic_client.application.qt import application
|
|
41
45
|
|
|
42
|
-
application(uri=uri, dev_mode=dev)
|
|
46
|
+
application(uri=uri, dev_mode=dev, debug=debug)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Centralised logging configuration for the Synodic Client.
|
|
2
2
|
|
|
3
|
-
Provides a rotating file handler with eager flushing
|
|
3
|
+
Provides a rotating file handler with eager flushing and runtime
|
|
4
|
+
log-level switching via :func:`set_debug_level`.
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
import logging
|
|
@@ -17,6 +18,8 @@ _MAX_BYTES = 5_242_880 # 5 MB
|
|
|
17
18
|
_BACKUP_COUNT = 3
|
|
18
19
|
_FORMAT = '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
|
|
19
20
|
|
|
21
|
+
_debug_active: bool = False
|
|
22
|
+
|
|
20
23
|
|
|
21
24
|
def log_path() -> Path:
|
|
22
25
|
"""Return the path to the application log file.
|
|
@@ -43,13 +46,18 @@ class EagerRotatingFileHandler(RotatingFileHandler):
|
|
|
43
46
|
self.flush()
|
|
44
47
|
|
|
45
48
|
|
|
46
|
-
def configure_logging() -> None:
|
|
49
|
+
def configure_logging(*, debug: bool = False) -> None:
|
|
47
50
|
"""Set up application-wide logging.
|
|
48
51
|
|
|
49
52
|
Attaches a :class:`EagerRotatingFileHandler` to the ``synodic_client``
|
|
50
53
|
and ``porringer`` loggers and configures :func:`logging.basicConfig`
|
|
51
54
|
for ``INFO`` level output on *stderr*.
|
|
52
55
|
|
|
56
|
+
Args:
|
|
57
|
+
debug: When ``True``, set the file handler and app logger to
|
|
58
|
+
``DEBUG`` level immediately. Equivalent to calling
|
|
59
|
+
:func:`set_debug_level` after configuration.
|
|
60
|
+
|
|
53
61
|
Safe to call more than once — subsequent calls are no-ops.
|
|
54
62
|
"""
|
|
55
63
|
app_logger = logging.getLogger('synodic_client')
|
|
@@ -80,3 +88,26 @@ def configure_logging() -> None:
|
|
|
80
88
|
porringer_logger.setLevel(logging.DEBUG)
|
|
81
89
|
else:
|
|
82
90
|
porringer_logger.setLevel(logging.INFO)
|
|
91
|
+
|
|
92
|
+
if debug:
|
|
93
|
+
set_debug_level(enabled=True)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def set_debug_level(*, enabled: bool) -> None:
|
|
97
|
+
"""Switch the app logger and file handler between DEBUG and INFO at runtime.
|
|
98
|
+
|
|
99
|
+
Safe to call at any time. Has no effect if logging has not been
|
|
100
|
+
configured yet.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
enabled: ``True`` for DEBUG, ``False`` for INFO.
|
|
104
|
+
"""
|
|
105
|
+
global _debug_active # noqa: PLW0603
|
|
106
|
+
_debug_active = enabled
|
|
107
|
+
level = logging.DEBUG if enabled else logging.INFO
|
|
108
|
+
|
|
109
|
+
app_logger = logging.getLogger('synodic_client')
|
|
110
|
+
app_logger.setLevel(level)
|
|
111
|
+
for h in app_logger.handlers:
|
|
112
|
+
if isinstance(h, EagerRotatingFileHandler):
|
|
113
|
+
h.setLevel(level)
|
|
@@ -114,6 +114,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:
|
|
|
114
114
|
|
|
115
115
|
auto_apply = user.auto_apply if user.auto_apply is not None else True
|
|
116
116
|
auto_start = user.auto_start if user.auto_start is not None else True
|
|
117
|
+
debug_logging = user.debug_logging if user.debug_logging is not None else False
|
|
117
118
|
|
|
118
119
|
return ResolvedConfig(
|
|
119
120
|
update_source=user.update_source,
|
|
@@ -125,6 +126,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:
|
|
|
125
126
|
prerelease_packages=user.prerelease_packages,
|
|
126
127
|
auto_apply=auto_apply,
|
|
127
128
|
auto_start=auto_start,
|
|
129
|
+
debug_logging=debug_logging,
|
|
128
130
|
last_client_update=user.last_client_update,
|
|
129
131
|
last_tool_updates=user.last_tool_updates,
|
|
130
132
|
)
|
|
@@ -100,6 +100,10 @@ class UserConfig(BaseModel):
|
|
|
100
100
|
# auto-startup.
|
|
101
101
|
auto_start: bool | None = None
|
|
102
102
|
|
|
103
|
+
# Enable verbose DEBUG-level logging to the log file.
|
|
104
|
+
# None resolves to False (INFO level).
|
|
105
|
+
debug_logging: bool | None = None
|
|
106
|
+
|
|
103
107
|
# ISO 8601 timestamp of the last successful client self-update.
|
|
104
108
|
# None means no update has been recorded.
|
|
105
109
|
last_client_update: str | None = None
|
|
@@ -231,5 +235,6 @@ class ResolvedConfig:
|
|
|
231
235
|
prerelease_packages: dict[str, list[str]] | None
|
|
232
236
|
auto_apply: bool
|
|
233
237
|
auto_start: bool
|
|
238
|
+
debug_logging: bool
|
|
234
239
|
last_client_update: str | None
|
|
235
240
|
last_tool_updates: dict[str, str] | None
|
|
@@ -7,6 +7,7 @@ and installation.
|
|
|
7
7
|
For non-installed (development) environments, updates are not supported.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import contextlib
|
|
10
11
|
import logging
|
|
11
12
|
import sys
|
|
12
13
|
from collections.abc import Callable
|
|
@@ -114,6 +115,12 @@ class Updater:
|
|
|
114
115
|
self._velopack_manager: Any = None
|
|
115
116
|
self._velopack_not_installed: bool = False
|
|
116
117
|
|
|
118
|
+
# Eagerly resolve the Velopack manager so that
|
|
119
|
+
# _current_version reflects the installed binary version
|
|
120
|
+
# rather than the (potentially stale) Python package metadata.
|
|
121
|
+
with contextlib.suppress(Exception):
|
|
122
|
+
self._get_velopack_manager()
|
|
123
|
+
|
|
117
124
|
logger.info(
|
|
118
125
|
'Updater created: version=%s, channel=%s, repo=%s',
|
|
119
126
|
self._current_version,
|
|
@@ -406,6 +413,11 @@ def initialize_velopack() -> None:
|
|
|
406
413
|
return
|
|
407
414
|
_VelopackState.initialized = True
|
|
408
415
|
|
|
416
|
+
# During post-update restarts Velopack's App.run() may exit the
|
|
417
|
+
# current process (to apply the update and relaunch). Each
|
|
418
|
+
# short-lived process writes "Initializing Velopack" to the shared
|
|
419
|
+
# log file before being replaced, so multiple entries followed by a
|
|
420
|
+
# single "initialized successfully" is expected behaviour.
|
|
409
421
|
logger.info('Initializing Velopack (exe=%s)', sys.executable)
|
|
410
422
|
try:
|
|
411
423
|
app = velopack.App()
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_controller.py
RENAMED
|
@@ -38,6 +38,7 @@ def _make_config(**overrides: Any) -> ResolvedConfig:
|
|
|
38
38
|
'prerelease_packages': None,
|
|
39
39
|
'auto_apply': True,
|
|
40
40
|
'auto_start': True,
|
|
41
|
+
'debug_logging': False,
|
|
41
42
|
'last_client_update': None,
|
|
42
43
|
'last_tool_updates': None,
|
|
43
44
|
}
|
|
@@ -49,6 +50,7 @@ def _make_controller(
|
|
|
49
50
|
*,
|
|
50
51
|
auto_apply: bool = True,
|
|
51
52
|
auto_update_interval_minutes: int = 0,
|
|
53
|
+
is_user_active: bool = False,
|
|
52
54
|
) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, MagicMock]:
|
|
53
55
|
"""Build an ``UpdateController`` with mocked collaborators.
|
|
54
56
|
|
|
@@ -69,7 +71,14 @@ def _make_controller(
|
|
|
69
71
|
mock_ucfg.return_value = MagicMock(
|
|
70
72
|
auto_update_interval_minutes=auto_update_interval_minutes,
|
|
71
73
|
)
|
|
72
|
-
controller = UpdateController(
|
|
74
|
+
controller = UpdateController(
|
|
75
|
+
app,
|
|
76
|
+
client,
|
|
77
|
+
banner,
|
|
78
|
+
settings_window=settings,
|
|
79
|
+
config=config,
|
|
80
|
+
)
|
|
81
|
+
controller.set_user_active_predicate(lambda: is_user_active)
|
|
73
82
|
|
|
74
83
|
return controller, app, client, banner, settings
|
|
75
84
|
|
|
@@ -189,6 +198,27 @@ class TestDownloadFinished:
|
|
|
189
198
|
UPDATE_STATUS_UP_TO_DATE_STYLE,
|
|
190
199
|
)
|
|
191
200
|
|
|
201
|
+
@staticmethod
|
|
202
|
+
def test_no_auto_apply_shows_restart_button() -> None:
|
|
203
|
+
"""When auto_apply=False, the restart button should be shown in settings."""
|
|
204
|
+
ctrl, app, client, banner, settings = _make_controller(auto_apply=False)
|
|
205
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
206
|
+
|
|
207
|
+
settings.show_restart_button.assert_called_once()
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def test_user_active_shows_restart_button() -> None:
|
|
211
|
+
"""When user is active, the restart button should be shown in settings."""
|
|
212
|
+
ctrl, app, client, banner, settings = _make_controller(
|
|
213
|
+
auto_apply=True,
|
|
214
|
+
is_user_active=True,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
with patch.object(ctrl, '_apply_update'):
|
|
218
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
219
|
+
|
|
220
|
+
settings.show_restart_button.assert_called_once()
|
|
221
|
+
|
|
192
222
|
@staticmethod
|
|
193
223
|
def test_download_failure_shows_error() -> None:
|
|
194
224
|
"""A failed download should show an error banner."""
|
|
@@ -199,6 +229,84 @@ class TestDownloadFinished:
|
|
|
199
229
|
settings.set_update_status.assert_called_with('Download failed', UPDATE_STATUS_ERROR_STYLE)
|
|
200
230
|
|
|
201
231
|
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# User-active gating
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class TestUserActiveGating:
|
|
238
|
+
"""Verify that auto-apply is deferred when the user is active.
|
|
239
|
+
|
|
240
|
+
Automatic checks always run so the settings window stays current.
|
|
241
|
+
Only the silent apply-and-restart is gated by ``_is_user_active``.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
@staticmethod
|
|
245
|
+
def test_auto_check_always_runs() -> None:
|
|
246
|
+
"""_on_auto_check should call _do_check even when user is active."""
|
|
247
|
+
ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
|
|
248
|
+
|
|
249
|
+
with patch.object(ctrl, '_do_check') as mock_check:
|
|
250
|
+
ctrl._on_auto_check()
|
|
251
|
+
|
|
252
|
+
mock_check.assert_called_once_with(silent=True)
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def test_manual_check_unaffected_by_active_user() -> None:
|
|
256
|
+
"""_on_manual_check should always call _do_check regardless of user activity."""
|
|
257
|
+
ctrl, _app, _client, banner, settings = _make_controller(is_user_active=True)
|
|
258
|
+
|
|
259
|
+
with patch.object(ctrl, '_do_check') as mock_check:
|
|
260
|
+
ctrl._on_manual_check()
|
|
261
|
+
|
|
262
|
+
mock_check.assert_called_once_with(silent=False)
|
|
263
|
+
|
|
264
|
+
@staticmethod
|
|
265
|
+
def test_auto_apply_deferred_when_user_active() -> None:
|
|
266
|
+
"""When auto_apply=True but user is active, show READY banner instead of applying."""
|
|
267
|
+
ctrl, app, client, banner, settings = _make_controller(
|
|
268
|
+
auto_apply=True,
|
|
269
|
+
is_user_active=True,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
with patch.object(ctrl, '_apply_update') as mock_apply:
|
|
273
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
274
|
+
|
|
275
|
+
mock_apply.assert_not_called()
|
|
276
|
+
assert banner.state.name == 'READY'
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def test_auto_apply_proceeds_when_user_inactive() -> None:
|
|
280
|
+
"""When auto_apply=True and user is inactive, _apply_update is called."""
|
|
281
|
+
ctrl, app, client, banner, settings = _make_controller(
|
|
282
|
+
auto_apply=True,
|
|
283
|
+
is_user_active=False,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
with patch.object(ctrl, '_apply_update') as mock_apply:
|
|
287
|
+
ctrl._on_download_finished(True, '2.0.0')
|
|
288
|
+
|
|
289
|
+
mock_apply.assert_called_once_with(silent=True)
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def test_can_auto_apply_false_when_user_active() -> None:
|
|
293
|
+
"""_can_auto_apply should return False when auto_apply=True but user is active."""
|
|
294
|
+
ctrl, *_ = _make_controller(auto_apply=True, is_user_active=True)
|
|
295
|
+
assert ctrl._can_auto_apply() is False
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def test_can_auto_apply_false_when_disabled() -> None:
|
|
299
|
+
"""_can_auto_apply should return False when auto_apply=False."""
|
|
300
|
+
ctrl, *_ = _make_controller(auto_apply=False, is_user_active=False)
|
|
301
|
+
assert ctrl._can_auto_apply() is False
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def test_can_auto_apply_true_when_enabled_and_inactive() -> None:
|
|
305
|
+
"""_can_auto_apply should return True only when auto_apply=True and user is inactive."""
|
|
306
|
+
ctrl, *_ = _make_controller(auto_apply=True, is_user_active=False)
|
|
307
|
+
assert ctrl._can_auto_apply() is True
|
|
308
|
+
|
|
309
|
+
|
|
202
310
|
# ---------------------------------------------------------------------------
|
|
203
311
|
# Apply update
|
|
204
312
|
# ---------------------------------------------------------------------------
|
|
@@ -226,6 +334,14 @@ class TestApplyUpdate:
|
|
|
226
334
|
client.apply_update_on_exit.assert_not_called()
|
|
227
335
|
app.quit.assert_not_called()
|
|
228
336
|
|
|
337
|
+
@staticmethod
|
|
338
|
+
def test_restart_requested_signal_triggers_apply() -> None:
|
|
339
|
+
"""The settings restart_requested signal should be connected to _apply_update."""
|
|
340
|
+
ctrl, app, client, banner, settings = _make_controller()
|
|
341
|
+
|
|
342
|
+
# Verify the signal was connected
|
|
343
|
+
settings.restart_requested.connect.assert_called_once_with(ctrl._apply_update)
|
|
344
|
+
|
|
229
345
|
|
|
230
346
|
# ---------------------------------------------------------------------------
|
|
231
347
|
# Settings changed → immediate check
|
|
@@ -36,7 +36,7 @@ class TestCli:
|
|
|
36
36
|
with patch('synodic_client.application.qt.application') as mock_app:
|
|
37
37
|
result = runner.invoke(app, [])
|
|
38
38
|
assert result.exit_code == 0
|
|
39
|
-
mock_app.assert_called_once_with(uri=None, dev_mode=False)
|
|
39
|
+
mock_app.assert_called_once_with(uri=None, dev_mode=False, debug=False)
|
|
40
40
|
|
|
41
41
|
@staticmethod
|
|
42
42
|
def test_launches_application_with_uri() -> None:
|
|
@@ -45,7 +45,7 @@ class TestCli:
|
|
|
45
45
|
with patch('synodic_client.application.qt.application') as mock_app:
|
|
46
46
|
result = runner.invoke(app, [test_uri])
|
|
47
47
|
assert result.exit_code == 0
|
|
48
|
-
mock_app.assert_called_once_with(uri=test_uri, dev_mode=False)
|
|
48
|
+
mock_app.assert_called_once_with(uri=test_uri, dev_mode=False, debug=False)
|
|
49
49
|
|
|
50
50
|
@staticmethod
|
|
51
51
|
def test_launches_application_with_dev_flag() -> None:
|
|
@@ -53,4 +53,4 @@ class TestCli:
|
|
|
53
53
|
with patch('synodic_client.application.qt.application') as mock_app:
|
|
54
54
|
result = runner.invoke(app, ['--dev'])
|
|
55
55
|
assert result.exit_code == 0
|
|
56
|
-
mock_app.assert_called_once_with(uri=None, dev_mode=True)
|
|
56
|
+
mock_app.assert_called_once_with(uri=None, dev_mode=True, debug=False)
|
|
@@ -70,7 +70,12 @@ class TestGithubReleaseAssetUrl:
|
|
|
70
70
|
@pytest.fixture
|
|
71
71
|
def updater() -> Updater:
|
|
72
72
|
"""Create an Updater instance for testing."""
|
|
73
|
-
|
|
73
|
+
u = Updater(current_version=Version('1.0.0'))
|
|
74
|
+
# Reset state cached by the eager _get_velopack_manager() call
|
|
75
|
+
# so each test can independently mock the Velopack SDK.
|
|
76
|
+
u._velopack_manager = None
|
|
77
|
+
u._velopack_not_installed = False
|
|
78
|
+
return u
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
@pytest.fixture
|
|
@@ -80,7 +85,10 @@ def updater_with_config() -> Updater:
|
|
|
80
85
|
repo_url='https://github.com/test/repo',
|
|
81
86
|
channel=UpdateChannel.DEVELOPMENT,
|
|
82
87
|
)
|
|
83
|
-
|
|
88
|
+
u = Updater(current_version=Version('1.0.0'), config=config)
|
|
89
|
+
u._velopack_manager = None
|
|
90
|
+
u._velopack_not_installed = False
|
|
91
|
+
return u
|
|
84
92
|
|
|
85
93
|
|
|
86
94
|
class TestUpdater:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/data.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/init.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/schema.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/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.dev51 → synodic_client-0.0.1.dev53}/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
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_install_preview.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_preview_model.py
RENAMED
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_tray_window_show.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_banner.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/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
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_startup.py
RENAMED
|
File without changes
|