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.
Files changed (76) hide show
  1. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/bootstrap.py +4 -3
  4. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/qt.py +44 -5
  5. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/install.py +17 -5
  6. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/plugin_row.py +28 -12
  7. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/screen.py +18 -0
  8. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/settings.py +46 -2
  9. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tool_update_controller.py +19 -1
  10. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tray.py +15 -2
  11. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/theme.py +6 -0
  12. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/update_controller.py +34 -3
  13. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/cli.py +5 -1
  14. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/logging.py +33 -2
  15. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/resolution.py +2 -0
  16. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/schema.py +5 -0
  17. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/updater.py +12 -0
  18. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_gather_packages.py +1 -0
  19. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_settings.py +1 -0
  20. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_controller.py +117 -1
  21. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_cli.py +3 -3
  22. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_config.py +1 -0
  23. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_resolution.py +1 -0
  24. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_updater.py +10 -2
  25. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/LICENSE.md +0 -0
  26. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/README.md +0 -0
  27. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/__main__.py +0 -0
  29. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/__init__.py +0 -0
  30. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/data.py +0 -0
  31. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/icon.py +0 -0
  32. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/init.py +0 -0
  33. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/instance.py +0 -0
  34. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/schema.py +0 -0
  35. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/__init__.py +0 -0
  36. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/action_card.py +0 -0
  37. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/card.py +0 -0
  38. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/install_workers.py +0 -0
  39. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/log_panel.py +0 -0
  40. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/projects.py +0 -0
  41. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/schema.py +0 -0
  42. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/sidebar.py +0 -0
  43. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/spinner.py +0 -0
  44. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/update_banner.py +0 -0
  45. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/uri.py +0 -0
  46. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/application/workers.py +0 -0
  47. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/client.py +0 -0
  48. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/config.py +0 -0
  49. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/protocol.py +0 -0
  50. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/py.typed +0 -0
  51. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/synodic_client/startup.py +0 -0
  52. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/conftest.py +0 -0
  54. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/conftest.py +0 -0
  57. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_action_card.py +0 -0
  58. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_install_preview.py +0 -0
  59. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_log_panel.py +0 -0
  60. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_logging.py +0 -0
  61. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_preview_model.py +0 -0
  62. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_sidebar.py +0 -0
  63. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_tray_window_show.py +0 -0
  64. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_banner.py +0 -0
  65. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_feedback.py +0 -0
  66. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_client_updater.py +0 -0
  67. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_client_version.py +0 -0
  68. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_examples.py +0 -0
  69. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_init.py +0 -0
  70. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_install.py +0 -0
  71. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev51 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev51
3
+ Version: 0.0.1.dev53
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev51"
18
+ version = "0.0.1.dev53"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -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 --dev flag early so logging uses the right filename.
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 still reserve space.
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
- if data.version:
360
- version_label = QLabel(data.version)
361
- version_label.setStyleSheet(PLUGIN_ROW_VERSION_STYLE)
362
- version_label.setMinimumWidth(PLUGIN_ROW_VERSION_MIN_WIDTH)
363
- version_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
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
- layout.addWidget(self._timestamp_label)
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.on_tool_update,
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)
@@ -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
@@ -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._auto_apply:
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()
@@ -41,6 +41,7 @@ def _make_config() -> ResolvedConfig:
41
41
  prerelease_packages=None,
42
42
  auto_apply=True,
43
43
  auto_start=False,
44
+ debug_logging=False,
44
45
  last_client_update=None,
45
46
  last_tool_updates=None,
46
47
  )
@@ -27,6 +27,7 @@ def _make_config(**overrides: Any) -> ResolvedConfig:
27
27
  'prerelease_packages': None,
28
28
  'auto_apply': True,
29
29
  'auto_start': True,
30
+ 'debug_logging': False,
30
31
  'last_client_update': None,
31
32
  'last_tool_updates': None,
32
33
  }
@@ -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(app, client, banner, settings, config)
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)
@@ -49,6 +49,7 @@ class TestUserConfig:
49
49
  assert config.prerelease_packages is None
50
50
  assert config.auto_apply is None
51
51
  assert config.auto_start is None
52
+ assert config.debug_logging is None
52
53
 
53
54
  @staticmethod
54
55
  def test_prerelease_packages_round_trip() -> None:
@@ -38,6 +38,7 @@ def _make_resolved(**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
  }
@@ -70,7 +70,12 @@ class TestGithubReleaseAssetUrl:
70
70
  @pytest.fixture
71
71
  def updater() -> Updater:
72
72
  """Create an Updater instance for testing."""
73
- return Updater(current_version=Version('1.0.0'))
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
- return Updater(current_version=Version('1.0.0'), config=config)
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: