synodic-client 0.0.1.dev52__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.dev52 → synodic_client-0.0.1.dev53}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/bootstrap.py +4 -3
  4. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/qt.py +10 -4
  5. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/screen.py +2 -3
  6. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/settings.py +30 -2
  7. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tray.py +8 -5
  8. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/update_controller.py +7 -7
  9. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/cli.py +5 -1
  10. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/logging.py +33 -2
  11. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/resolution.py +2 -0
  12. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/schema.py +5 -0
  13. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/updater.py +12 -0
  14. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_gather_packages.py +1 -0
  15. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_settings.py +1 -0
  16. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_controller.py +37 -13
  17. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_cli.py +3 -3
  18. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_config.py +1 -0
  19. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_resolution.py +1 -0
  20. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_updater.py +10 -2
  21. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/LICENSE.md +0 -0
  22. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/README.md +0 -0
  23. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/__init__.py +0 -0
  24. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/__main__.py +0 -0
  25. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/__init__.py +0 -0
  26. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/data.py +0 -0
  27. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/icon.py +0 -0
  28. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/init.py +0 -0
  29. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/instance.py +0 -0
  30. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/schema.py +0 -0
  31. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/__init__.py +0 -0
  32. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/action_card.py +0 -0
  33. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/card.py +0 -0
  34. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/install.py +0 -0
  35. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/install_workers.py +0 -0
  36. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/log_panel.py +0 -0
  37. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/plugin_row.py +0 -0
  38. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/projects.py +0 -0
  39. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/schema.py +0 -0
  40. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/sidebar.py +0 -0
  41. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/spinner.py +0 -0
  42. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/tool_update_controller.py +0 -0
  43. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/screen/update_banner.py +0 -0
  44. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/theme.py +0 -0
  45. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/uri.py +0 -0
  46. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/application/workers.py +0 -0
  47. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/client.py +0 -0
  48. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/config.py +0 -0
  49. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/protocol.py +0 -0
  50. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/py.typed +0 -0
  51. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/synodic_client/startup.py +0 -0
  52. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/__init__.py +0 -0
  53. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/conftest.py +0 -0
  54. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/conftest.py +0 -0
  57. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_action_card.py +0 -0
  58. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_install_preview.py +0 -0
  59. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_log_panel.py +0 -0
  60. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_logging.py +0 -0
  61. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_preview_model.py +0 -0
  62. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_sidebar.py +0 -0
  63. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_tray_window_show.py +0 -0
  64. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_banner.py +0 -0
  65. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/qt/test_update_feedback.py +0 -0
  66. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_client_updater.py +0 -0
  67. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_client_version.py +0 -0
  68. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_examples.py +0 -0
  69. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_init.py +0 -0
  70. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_install.py +0 -0
  71. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_uri.py +0 -0
  72. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/test_workers.py +0 -0
  73. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/windows/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/windows/conftest.py +0 -0
  75. {synodic_client-0.0.1.dev52 → synodic_client-0.0.1.dev53}/tests/unit/windows/test_protocol.py +0 -0
  76. {synodic_client-0.0.1.dev52 → 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.dev52
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.dev52"
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)
@@ -24,7 +24,7 @@ from synodic_client.application.screen.tray import TrayScreen
24
24
  from synodic_client.application.uri import parse_uri
25
25
  from synodic_client.client import Client
26
26
  from synodic_client.config import set_dev_mode
27
- from synodic_client.logging import configure_logging
27
+ from synodic_client.logging import configure_logging, set_debug_level
28
28
  from synodic_client.protocol import extract_uri_from_args
29
29
  from synodic_client.resolution import (
30
30
  ResolvedConfig,
@@ -104,7 +104,7 @@ class _TopLevelShowFilter(QObject):
104
104
  ):
105
105
  geo = obj.geometry()
106
106
  stack = ''.join(traceback.format_stack(limit=12))
107
- self._diag_logger.warning(
107
+ self._diag_logger.debug(
108
108
  '[DIAG] Top-level window %s: class=%s title=%r geo=(%d,%d %dx%d) visible=%s\n%s',
109
109
  event.type().name,
110
110
  type(obj).__qualname__,
@@ -149,7 +149,7 @@ def _init_app() -> QApplication:
149
149
  return app
150
150
 
151
151
 
152
- 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:
153
153
  """Application entry point.
154
154
 
155
155
  Args:
@@ -159,13 +159,14 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
159
159
  log files, or single-instance locks with the user-installed
160
160
  application. Velopack initialisation and protocol
161
161
  registration are skipped.
162
+ debug: When ``True``, enable DEBUG-level file logging.
162
163
  """
163
164
  # Activate dev-mode namespacing before anything reads config paths.
164
165
  set_dev_mode(dev_mode)
165
166
 
166
167
  # Configure logging before Velopack so install/uninstall hooks and
167
168
  # first-run diagnostics are captured in the log file.
168
- configure_logging()
169
+ configure_logging(debug=debug)
169
170
  logger = logging.getLogger('synodic_client')
170
171
  _install_exception_hook(logger)
171
172
 
@@ -180,6 +181,11 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
180
181
 
181
182
  client, porringer, config = _init_services(logger)
182
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
+
183
189
  app = _init_app()
184
190
 
185
191
  loop = qasync.QEventLoop(app)
@@ -199,8 +199,7 @@ class ToolsView(QWidget):
199
199
  """Schedule an asynchronous rebuild of the tool list."""
200
200
  if self._refresh_in_progress:
201
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)
202
+ logger.debug('ToolsView.refresh() called (visible=%s)', self.isVisible())
204
203
  asyncio.create_task(self._async_refresh())
205
204
 
206
205
  async def _async_refresh(self) -> None:
@@ -1112,7 +1111,7 @@ class MainWindow(QMainWindow):
1112
1111
  """[DIAG] Log every show event with a stack trace."""
1113
1112
  geo = self.geometry()
1114
1113
  stack = ''.join(traceback.format_stack(limit=10))
1115
- logger.warning(
1114
+ logger.debug(
1116
1115
  '[DIAG] MainWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
1117
1116
  geo.x(),
1118
1117
  geo.y(),
@@ -32,7 +32,7 @@ from synodic_client.application.icon import app_icon
32
32
  from synodic_client.application.screen import _format_relative_time
33
33
  from synodic_client.application.screen.card import CardFrame
34
34
  from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
35
- from synodic_client.logging import log_path
35
+ from synodic_client.logging import log_path, set_debug_level
36
36
  from synodic_client.resolution import ResolvedConfig, update_user_config
37
37
  from synodic_client.schema import GITHUB_REPO_URL
38
38
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
@@ -54,11 +54,14 @@ class SettingsWindow(QMainWindow):
54
54
  check_updates_requested = Signal()
55
55
  """Emitted when the user clicks the *Check for Updates* button."""
56
56
 
57
+ restart_requested = Signal()
58
+ """Emitted when the user clicks the *Restart & Update* button."""
59
+
57
60
  def showEvent(self, event: QShowEvent) -> None: # noqa: N802
58
61
  """[DIAG] Log every show event with a stack trace."""
59
62
  geo = self.geometry()
60
63
  stack = ''.join(traceback.format_stack(limit=10))
61
- logger.warning(
64
+ logger.debug(
62
65
  '[DIAG] SettingsWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
63
66
  geo.x(),
64
67
  geo.y(),
@@ -199,6 +202,12 @@ class SettingsWindow(QMainWindow):
199
202
  self._update_status_label = QLabel('')
200
203
  self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
201
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
+
202
211
  row.addStretch()
203
212
  content.addLayout(row)
204
213
 
@@ -218,6 +227,12 @@ class SettingsWindow(QMainWindow):
218
227
  def _build_advanced_section(self) -> CardFrame:
219
228
  """Construct the *Advanced* settings card."""
220
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
+
221
236
  row = QHBoxLayout()
222
237
  open_log_btn = QPushButton('Open Log\u2026')
223
238
  open_log_btn.clicked.connect(self._open_log)
@@ -254,6 +269,9 @@ class SettingsWindow(QMainWindow):
254
269
  self._auto_apply_check.setChecked(config.auto_apply)
255
270
  self._auto_start_check.setChecked(is_startup_registered())
256
271
 
272
+ # Debug logging
273
+ self._debug_logging_check.setChecked(config.debug_logging)
274
+
257
275
  # Last client update timestamp
258
276
  if config.last_client_update:
259
277
  relative = _format_relative_time(config.last_client_update)
@@ -275,6 +293,7 @@ class SettingsWindow(QMainWindow):
275
293
  def set_checking(self) -> None:
276
294
  """Enter the *checking* state — disable button and show status."""
277
295
  self._check_updates_btn.setEnabled(False)
296
+ self._restart_btn.hide()
278
297
  self._update_status_label.setText('Checking\u2026')
279
298
  self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
280
299
 
@@ -282,6 +301,10 @@ class SettingsWindow(QMainWindow):
282
301
  """Re-enable the *Check for Updates* button after a check completes."""
283
302
  self._check_updates_btn.setEnabled(True)
284
303
 
304
+ def show_restart_button(self) -> None:
305
+ """Show the *Restart & Update* button."""
306
+ self._restart_btn.show()
307
+
285
308
  def show(self) -> None:
286
309
  """Sync controls from config, then show the window."""
287
310
  self.sync_from_config()
@@ -313,6 +336,7 @@ class SettingsWindow(QMainWindow):
313
336
  self._detect_updates_check,
314
337
  self._auto_apply_check,
315
338
  self._auto_start_check,
339
+ self._debug_logging_check,
316
340
  self._check_updates_btn,
317
341
  )
318
342
  for w in widgets:
@@ -362,6 +386,10 @@ class SettingsWindow(QMainWindow):
362
386
  remove_startup()
363
387
  self.settings_changed.emit(self._config)
364
388
 
389
+ def _on_debug_logging_changed(self, checked: bool) -> None:
390
+ set_debug_level(enabled=checked)
391
+ self._persist(debug_logging=checked)
392
+
365
393
  @staticmethod
366
394
  def _open_log() -> None:
367
395
  """Open the log file in the system's default editor."""
@@ -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
  )
@@ -130,13 +131,15 @@ class TrayScreen:
130
131
  """Show the settings window."""
131
132
  self._settings_window.show()
132
133
 
133
- def _is_user_active(self) -> bool:
134
- """Return ``True`` when the user has a visible window.
134
+ @staticmethod
135
+ def _is_user_active() -> bool:
136
+ """Return ``True`` when the user has a visible application window.
135
137
 
136
- Used by the update controllers to defer automatic updates
137
- while the user is actively interacting with the application.
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.
138
141
  """
139
- return self._window.isVisible() or self._settings_window.isVisible()
142
+ return any(w.isVisible() for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow))
140
143
 
141
144
  def _on_settings_changed(self, config: ResolvedConfig) -> None:
142
145
  """React to a change made in the settings window."""
@@ -56,7 +56,7 @@ class UpdateController:
56
56
  Optional pre-resolved configuration. ``None`` resolves from disk.
57
57
  is_user_active:
58
58
  Predicate returning ``True`` when the user has a visible window.
59
- Automatic checks and auto-apply are deferred while active.
59
+ Auto-apply is deferred while active; checks still run normally.
60
60
  """
61
61
 
62
62
  def __init__(
@@ -99,9 +99,10 @@ class UpdateController:
99
99
 
100
100
  # Wire settings check-updates button
101
101
  self._settings_window.check_updates_requested.connect(self._on_manual_check)
102
+ self._settings_window.restart_requested.connect(self._apply_update)
102
103
 
103
104
  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
+ """Set the predicate used to defer auto-apply when the user is active.
105
106
 
106
107
  Args:
107
108
  predicate: Returns ``True`` when the user has a visible window.
@@ -200,12 +201,10 @@ class UpdateController:
200
201
  def _on_auto_check(self) -> None:
201
202
  """Handle automatic (periodic) check — silent.
202
203
 
203
- Skipped when the user has a visible window to avoid disruptive
204
- downloads and auto-apply restarts. The next timer tick retries.
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`.
205
207
  """
206
- if self._is_user_active():
207
- logger.debug('Automatic update check deferred — user is active')
208
- return
209
208
  self._do_check(silent=True)
210
209
 
211
210
  def _do_check(self, *, silent: bool) -> None:
@@ -324,6 +323,7 @@ class UpdateController:
324
323
  f'v{version} ready',
325
324
  UPDATE_STATUS_UP_TO_DATE_STYLE,
326
325
  )
326
+ self._settings_window.show_restart_button()
327
327
 
328
328
  def _on_download_error(self, error: str) -> None:
329
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
  }
@@ -197,6 +198,27 @@ class TestDownloadFinished:
197
198
  UPDATE_STATUS_UP_TO_DATE_STYLE,
198
199
  )
199
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
+
200
222
  @staticmethod
201
223
  def test_download_failure_shows_error() -> None:
202
224
  """A failed download should show an error banner."""
@@ -213,22 +235,16 @@ class TestDownloadFinished:
213
235
 
214
236
 
215
237
  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()
238
+ """Verify that auto-apply is deferred when the user is active.
225
239
 
226
- mock_check.assert_not_called()
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
+ """
227
243
 
228
244
  @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)
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)
232
248
 
233
249
  with patch.object(ctrl, '_do_check') as mock_check:
234
250
  ctrl._on_auto_check()
@@ -318,6 +334,14 @@ class TestApplyUpdate:
318
334
  client.apply_update_on_exit.assert_not_called()
319
335
  app.quit.assert_not_called()
320
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
+
321
345
 
322
346
  # ---------------------------------------------------------------------------
323
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: