synodic-client 0.0.1.dev50__tar.gz → 0.0.1.dev52__tar.gz

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