synodic-client 0.0.1.dev40__tar.gz → 0.0.1.dev42__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 (69) hide show
  1. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/pyproject.toml +1 -1
  3. synodic_client-0.0.1.dev42/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/screen.py +59 -61
  5. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/settings.py +20 -3
  6. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/tray.py +13 -186
  7. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/theme.py +15 -0
  8. synodic_client-0.0.1.dev42/synodic_client/application/update_controller.py +312 -0
  9. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/config.py +4 -0
  10. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/resolution.py +3 -0
  11. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_gather_packages.py +25 -44
  12. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_settings.py +5 -3
  13. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_tray_window_show.py +1 -0
  14. synodic_client-0.0.1.dev42/tests/unit/qt/test_update_controller.py +298 -0
  15. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_config.py +1 -0
  16. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_resolution.py +1 -0
  17. synodic_client-0.0.1.dev40/synodic_client/_version.py +0 -1
  18. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/LICENSE.md +0 -0
  19. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/README.md +0 -0
  20. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/__main__.py +0 -0
  22. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/__init__.py +0 -0
  23. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/bootstrap.py +0 -0
  24. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/data.py +0 -0
  25. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/icon.py +0 -0
  26. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/instance.py +0 -0
  27. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/qt.py +0 -0
  28. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/__init__.py +0 -0
  29. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/action_card.py +0 -0
  30. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/card.py +0 -0
  31. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/install.py +0 -0
  32. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/log_panel.py +0 -0
  33. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/sidebar.py +0 -0
  34. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/spinner.py +0 -0
  35. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/screen/update_banner.py +0 -0
  36. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/uri.py +0 -0
  37. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/application/workers.py +0 -0
  38. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/cli.py +0 -0
  39. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/client.py +0 -0
  40. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/logging.py +0 -0
  41. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/protocol.py +0 -0
  42. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/py.typed +0 -0
  43. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/startup.py +0 -0
  44. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/synodic_client/updater.py +0 -0
  45. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/__init__.py +0 -0
  46. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/conftest.py +0 -0
  47. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/__init__.py +0 -0
  49. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/conftest.py +0 -0
  50. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_action_card.py +0 -0
  51. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_install_preview.py +0 -0
  52. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_log_panel.py +0 -0
  53. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_logging.py +0 -0
  54. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_preview_model.py +0 -0
  55. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_sidebar.py +0 -0
  56. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_banner.py +0 -0
  57. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/qt/test_update_feedback.py +0 -0
  58. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_cli.py +0 -0
  59. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_client_updater.py +0 -0
  60. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_client_version.py +0 -0
  61. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_examples.py +0 -0
  62. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_install.py +0 -0
  63. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_updater.py +0 -0
  64. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_uri.py +0 -0
  65. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/test_workers.py +0 -0
  66. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/windows/__init__.py +0 -0
  67. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/windows/conftest.py +0 -0
  68. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/tests/unit/windows/test_protocol.py +0 -0
  69. {synodic_client-0.0.1.dev40 → synodic_client-0.0.1.dev42}/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.dev40
3
+ Version: 0.0.1.dev42
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.dev40"
18
+ version = "0.0.1.dev42"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev42'
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
 
9
9
  from porringer.api import API
10
10
  from porringer.backend.builder import Builder
11
+ from porringer.backend.command.core.discovery import DiscoveredPlugins
11
12
  from porringer.core.plugin_schema.plugin_manager import PluginManager
12
13
  from porringer.core.plugin_schema.project_environment import ProjectEnvironment
13
14
  from porringer.schema import (
@@ -1158,26 +1159,29 @@ class ToolsView(QWidget):
1158
1159
  return None
1159
1160
  return {name for name, chip in self._filter_chips.items() if chip.isChecked()}
1160
1161
 
1161
- def _apply_filter(self, _text: str | None = None) -> None:
1162
- """Show/hide section widgets based on search text and active chips.
1162
+ @staticmethod
1163
+ def _is_plugin_active(plugin_name: str, active: set[str] | None) -> bool:
1164
+ """Return whether *plugin_name* passes the chip filter."""
1165
+ return active is None or plugin_name in active
1163
1166
 
1164
- A single pass walks ``_section_widgets`` tracking the current
1165
- plugin and kind. Visibility rules:
1167
+ @staticmethod
1168
+ def _finalise_provider(
1169
+ provider: PluginProviderHeader | None,
1170
+ has_visible: bool,
1171
+ ) -> bool:
1172
+ """Set provider visibility and return whether it had visible children."""
1173
+ if provider is not None:
1174
+ provider.setVisible(has_visible)
1175
+ return has_visible
1166
1176
 
1167
- * **PluginProviderHeader** visible when its plugin is in the
1168
- active chip set **and** at least one child row matches the
1169
- search text.
1170
- * **PluginRow** — visible when its plugin is active **and** its
1171
- package name or plugin name contains the search text.
1172
- * **ProjectChildRow** — follows its parent :class:`PluginRow`.
1173
- * **PluginKindHeader** — visible when at least one child
1174
- provider in its kind group is visible.
1177
+ def _apply_filter(self, _text: str | None = None) -> None:
1178
+ """Show/hide section widgets based on search text and active chips.
1175
1179
 
1176
- After the pass, kind headers with no visible children are hidden.
1180
+ Delegates to :meth:`_is_plugin_active` for chip matching and
1181
+ :meth:`_finalise_provider` for provider visibility bookkeeping.
1177
1182
  """
1178
1183
  query = self._search_input.text().strip().lower()
1179
1184
  active = self._active_chip_plugins()
1180
- all_active = active is None # None → no chips yet, show all
1181
1185
 
1182
1186
  current_kind_header: PluginKindHeader | None = None
1183
1187
  kind_has_visible = False
@@ -1187,13 +1191,8 @@ class ToolsView(QWidget):
1187
1191
 
1188
1192
  for widget in self._section_widgets:
1189
1193
  if isinstance(widget, PluginKindHeader):
1190
- # Finalise previous kind
1194
+ kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child)
1191
1195
  if current_kind_header is not None:
1192
- # Finalise last provider of previous kind
1193
- if current_provider is not None:
1194
- current_provider.setVisible(provider_has_visible_child)
1195
- if provider_has_visible_child:
1196
- kind_has_visible = True
1197
1196
  current_kind_header.setVisible(kind_has_visible)
1198
1197
 
1199
1198
  current_kind_header = widget
@@ -1202,31 +1201,20 @@ class ToolsView(QWidget):
1202
1201
  provider_has_visible_child = False
1203
1202
 
1204
1203
  elif isinstance(widget, PluginProviderHeader):
1205
- # Finalise previous provider
1206
- if current_provider is not None:
1207
- current_provider.setVisible(provider_has_visible_child)
1208
- if provider_has_visible_child:
1209
- kind_has_visible = True
1204
+ kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child)
1210
1205
 
1211
1206
  current_provider = widget
1212
1207
  provider_has_visible_child = False
1213
- plugin_name = widget._plugin_name
1214
- plugin_active = all_active or (active is not None and plugin_name in active)
1215
-
1216
- if not plugin_active:
1217
- # Entire provider hidden
1208
+ if not self._is_plugin_active(widget._plugin_name, active):
1218
1209
  widget.setVisible(False)
1219
- provider_has_visible_child = False
1220
1210
 
1221
1211
  elif isinstance(widget, PluginRow):
1222
- plugin_name = widget._plugin_name
1223
- plugin_active = all_active or (active is not None and plugin_name in active)
1224
- if not plugin_active:
1212
+ if not self._is_plugin_active(widget._plugin_name, active):
1225
1213
  widget.setVisible(False)
1226
1214
  parent_row_visible = False
1227
1215
  continue
1228
1216
 
1229
- name_match = not query or query in widget._package_name.lower() or query in plugin_name.lower()
1217
+ name_match = not query or query in widget._package_name.lower() or query in widget._plugin_name.lower()
1230
1218
  widget.setVisible(name_match)
1231
1219
  parent_row_visible = name_match
1232
1220
  if name_match:
@@ -1236,10 +1224,7 @@ class ToolsView(QWidget):
1236
1224
  widget.setVisible(parent_row_visible)
1237
1225
 
1238
1226
  # Finalise last provider and kind
1239
- if current_provider is not None:
1240
- current_provider.setVisible(provider_has_visible_child)
1241
- if provider_has_visible_child:
1242
- kind_has_visible = True
1227
+ kind_has_visible |= self._finalise_provider(current_provider, provider_has_visible_child)
1243
1228
  if current_kind_header is not None:
1244
1229
  current_kind_header.setVisible(kind_has_visible)
1245
1230
 
@@ -1834,6 +1819,7 @@ class ProjectsView(QWidget):
1834
1819
  if self._coordinator is not None:
1835
1820
  snapshot = await self._coordinator.refresh()
1836
1821
  results = snapshot.validated_directories
1822
+ discovered = snapshot.discovered
1837
1823
  else:
1838
1824
  loop = asyncio.get_running_loop()
1839
1825
  results = await loop.run_in_executor(
@@ -1843,6 +1829,7 @@ class ProjectsView(QWidget):
1843
1829
  check_manifest=True,
1844
1830
  ),
1845
1831
  )
1832
+ discovered = None
1846
1833
 
1847
1834
  directories: list[tuple[Path, str, bool]] = []
1848
1835
  current_paths: set[Path] = set()
@@ -1854,32 +1841,12 @@ class ProjectsView(QWidget):
1854
1841
  current_paths.add(path)
1855
1842
 
1856
1843
  # Remove widgets for directories no longer in cache
1857
- for path in list(self._widgets):
1858
- if path not in current_paths:
1859
- widget = self._widgets.pop(path)
1860
- self._stack.removeWidget(widget)
1861
- widget.reset()
1862
- widget.deleteLater()
1844
+ self._remove_stale_widgets(current_paths)
1863
1845
 
1864
1846
  # Grab pre-discovered plugins so each widget can skip redundant discovery
1865
- discovered = snapshot.discovered if self._coordinator is not None else None
1866
1847
 
1867
1848
  # Create new widgets for new directories
1868
- for path, _name, valid in directories:
1869
- if path not in self._widgets and valid:
1870
- widget = SetupPreviewWidget(
1871
- self._porringer,
1872
- self,
1873
- show_close=False,
1874
- config=self._config,
1875
- )
1876
- widget._discovered_plugins = discovered
1877
- widget.install_finished.connect(self._on_install_finished)
1878
- widget.phase_changed.connect(
1879
- lambda phase, p=path: self._on_widget_phase_changed(p, phase),
1880
- )
1881
- self._widgets[path] = widget
1882
- self._stack.addWidget(widget)
1849
+ self._create_directory_widgets(directories, discovered)
1883
1850
 
1884
1851
  # Rebuild sidebar
1885
1852
  self._sidebar.set_directories(directories)
@@ -1909,6 +1876,37 @@ class ProjectsView(QWidget):
1909
1876
 
1910
1877
  # --- Event handlers ---
1911
1878
 
1879
+ def _remove_stale_widgets(self, current_paths: set[Path]) -> None:
1880
+ """Remove stacked widgets for directories no longer in the cache."""
1881
+ for path in list(self._widgets):
1882
+ if path not in current_paths:
1883
+ widget = self._widgets.pop(path)
1884
+ self._stack.removeWidget(widget)
1885
+ widget.reset()
1886
+ widget.deleteLater()
1887
+
1888
+ def _create_directory_widgets(
1889
+ self,
1890
+ directories: list[tuple[Path, str, bool]],
1891
+ discovered: DiscoveredPlugins | None,
1892
+ ) -> None:
1893
+ """Create :class:`SetupPreviewWidget` instances for new valid directories."""
1894
+ for path, _name, valid in directories:
1895
+ if path not in self._widgets and valid:
1896
+ widget = SetupPreviewWidget(
1897
+ self._porringer,
1898
+ self,
1899
+ show_close=False,
1900
+ config=self._config,
1901
+ )
1902
+ widget._discovered_plugins = discovered
1903
+ widget.install_finished.connect(self._on_install_finished)
1904
+ widget.phase_changed.connect(
1905
+ lambda phase, p=path: self._on_widget_phase_changed(p, phase),
1906
+ )
1907
+ self._widgets[path] = widget
1908
+ self._stack.addWidget(widget)
1909
+
1912
1910
  def _on_selection_changed(self, path: Path) -> None:
1913
1911
  """Handle sidebar selection — switch the stacked widget."""
1914
1912
  widget = self._widgets.get(path)
@@ -29,7 +29,7 @@ from PySide6.QtWidgets import (
29
29
 
30
30
  from synodic_client.application.icon import app_icon
31
31
  from synodic_client.application.screen.card import CardFrame
32
- from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
32
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
33
33
  from synodic_client.logging import log_path
34
34
  from synodic_client.resolution import ResolvedConfig, update_user_config
35
35
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
@@ -163,6 +163,11 @@ class SettingsWindow(QMainWindow):
163
163
  self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
164
164
  content.addWidget(self._detect_updates_check)
165
165
 
166
+ # Automatically apply updates
167
+ self._auto_apply_check = QCheckBox('Automatically apply updates')
168
+ self._auto_apply_check.toggled.connect(self._on_auto_apply_changed)
169
+ content.addWidget(self._auto_apply_check)
170
+
166
171
  # Check for Updates
167
172
  row = QHBoxLayout()
168
173
  self._check_updates_btn = QPushButton('Check for Updates\u2026')
@@ -218,16 +223,24 @@ class SettingsWindow(QMainWindow):
218
223
 
219
224
  # Checkboxes
220
225
  self._detect_updates_check.setChecked(config.detect_updates)
226
+ self._auto_apply_check.setChecked(config.auto_apply)
221
227
  self._auto_start_check.setChecked(is_startup_registered())
222
228
 
223
- def set_update_status(self, text: str) -> None:
224
- """Set the inline status text next to the *Check for Updates* button."""
229
+ def set_update_status(self, text: str, style: str = '') -> None:
230
+ """Set the inline status text next to the *Check for Updates* button.
231
+
232
+ Args:
233
+ text: The status message.
234
+ style: Optional stylesheet for the label (e.g. color).
235
+ """
225
236
  self._update_status_label.setText(text)
237
+ self._update_status_label.setStyleSheet(style)
226
238
 
227
239
  def set_checking(self) -> None:
228
240
  """Enter the *checking* state — disable button and show status."""
229
241
  self._check_updates_btn.setEnabled(False)
230
242
  self._update_status_label.setText('Checking\u2026')
243
+ self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)
231
244
 
232
245
  def reset_check_updates_button(self) -> None:
233
246
  """Re-enable the *Check for Updates* button after a check completes."""
@@ -262,6 +275,7 @@ class SettingsWindow(QMainWindow):
262
275
  self._auto_update_spin,
263
276
  self._tool_update_spin,
264
277
  self._detect_updates_check,
278
+ self._auto_apply_check,
265
279
  self._auto_start_check,
266
280
  self._check_updates_btn,
267
281
  )
@@ -301,6 +315,9 @@ class SettingsWindow(QMainWindow):
301
315
  def _on_detect_updates_changed(self, checked: bool) -> None:
302
316
  self._persist(detect_updates=checked)
303
317
 
318
+ def _on_auto_apply_changed(self, checked: bool) -> None:
319
+ self._persist(auto_apply=checked)
320
+
304
321
  def _on_auto_start_changed(self, checked: bool) -> None:
305
322
  self._config = update_user_config(auto_start=checked)
306
323
  if checked:
@@ -17,10 +17,9 @@ from PySide6.QtWidgets import (
17
17
  from synodic_client.application.icon import app_icon
18
18
  from synodic_client.application.screen.screen import MainWindow, ToolsView
19
19
  from synodic_client.application.screen.settings import SettingsWindow
20
+ from synodic_client.application.update_controller import UpdateController
20
21
  from synodic_client.application.workers import (
21
22
  ToolUpdateResult,
22
- check_for_update,
23
- download_update,
24
23
  run_package_remove,
25
24
  run_tool_updates,
26
25
  )
@@ -31,7 +30,6 @@ from synodic_client.resolution import (
31
30
  resolve_config,
32
31
  resolve_update_config,
33
32
  )
34
- from synodic_client.updater import UpdateInfo
35
33
 
36
34
  logger = logging.getLogger(__name__)
37
35
 
@@ -59,7 +57,6 @@ class TrayScreen:
59
57
  self._client = client
60
58
  self._window = window
61
59
  self._config = config
62
- self._update_task: asyncio.Task[None] | None = None
63
60
  self._tool_task: asyncio.Task[None] | None = None
64
61
 
65
62
  self.tray_icon = app_icon()
@@ -74,14 +71,19 @@ class TrayScreen:
74
71
  # Settings window (created once, shown/hidden on demand)
75
72
  self._settings_window = SettingsWindow(self._resolve_config())
76
73
  self._settings_window.settings_changed.connect(self._on_settings_changed)
77
- self._settings_window.check_updates_requested.connect(self._on_check_updates)
78
74
 
79
75
  # MainWindow gear button → open settings
80
76
  window.settings_requested.connect(self._show_settings)
81
77
 
82
- # Periodic auto-update checking
83
- self._auto_update_timer: QTimer | None = None
84
- self._restart_auto_update_timer()
78
+ # Update controller — owns the self-update lifecycle & timer
79
+ self._banner = window.update_banner
80
+ self._update_controller = UpdateController(
81
+ app,
82
+ client,
83
+ self._banner,
84
+ self._settings_window,
85
+ config,
86
+ )
85
87
 
86
88
  # Periodic tool update checking
87
89
  self._tool_update_timer: QTimer | None = None
@@ -90,11 +92,6 @@ class TrayScreen:
90
92
  # Connect ToolsView signals — deferred because ToolsView is created lazily
91
93
  window.tools_view_created.connect(self._connect_tools_view)
92
94
 
93
- # Connect update banner signals
94
- self._banner = window.update_banner
95
- self._banner.restart_requested.connect(self._apply_update)
96
- self._banner.retry_requested.connect(lambda: self._do_check_updates(silent=True))
97
-
98
95
  def _build_menu(self, app: QApplication, window: MainWindow) -> None:
99
96
  """Build the tray context menu."""
100
97
  self.menu = QMenu()
@@ -105,12 +102,6 @@ class TrayScreen:
105
102
 
106
103
  self.menu.addSeparator()
107
104
 
108
- self.update_action = QAction('Check for Updates...', self.menu)
109
- self.update_action.triggered.connect(self._on_check_updates)
110
- self.menu.addAction(self.update_action)
111
-
112
- self.menu.addSeparator()
113
-
114
105
  self.settings_action = QAction('Settings\u2026', self.menu)
115
106
  self.settings_action.triggered.connect(self._show_settings)
116
107
  self.menu.addAction(self.settings_action)
@@ -172,16 +163,6 @@ class TrayScreen:
172
163
  logger.info('%s enabled (every %d minute(s))', label, interval_minutes)
173
164
  return timer
174
165
 
175
- def _restart_auto_update_timer(self) -> None:
176
- """Start (or restart) the periodic auto-update timer from config."""
177
- config = resolve_update_config(self._resolve_config())
178
- self._auto_update_timer = self._restart_timer(
179
- self._auto_update_timer,
180
- config.auto_update_interval_minutes,
181
- self._on_auto_check_updates,
182
- 'Automatic update checking',
183
- )
184
-
185
166
  def _restart_tool_update_timer(self) -> None:
186
167
  """Start (or restart) the periodic tool update timer from config."""
187
168
  config = resolve_update_config(self._resolve_config())
@@ -206,116 +187,10 @@ class TrayScreen:
206
187
  def _on_settings_changed(self, config: ResolvedConfig) -> None:
207
188
  """React to a change made in the settings window."""
208
189
  self._config = config
209
- self._reinitialize_updater(config)
210
-
211
- def _reinitialize_updater(self, config: ResolvedConfig) -> None:
212
- """Re-derive update settings and restart the updater and timers.
213
-
214
- The new ``Updater`` starts with the ``importlib.metadata``
215
- version which may be stale after a Velopack update. The
216
- authoritative Velopack version is recovered automatically on
217
- the first ``_get_velopack_manager()`` call (i.e. the next
218
- update check), so no special handling is required here.
219
- """
220
- update_cfg = resolve_update_config(config)
221
- self._client.initialize_updater(update_cfg)
222
- self._restart_auto_update_timer()
190
+ # Delegate updater reinit + immediate check to the controller
191
+ self._update_controller.on_settings_changed(config)
192
+ # Restart tool-update timer with new config
223
193
  self._restart_tool_update_timer()
224
- logger.info('Updater re-initialized (channel: %s, source: %s)', update_cfg.channel.name, update_cfg.repo_url)
225
-
226
- def _reset_update_action(self) -> None:
227
- """Restore the 'Check for Updates' action to its idle state."""
228
- self.update_action.setEnabled(True)
229
- self.update_action.setText('Check for Updates...')
230
-
231
- def _on_check_updates(self) -> None:
232
- """Handle manual check for updates action."""
233
- self._do_check_updates(silent=False)
234
-
235
- def _on_auto_check_updates(self) -> None:
236
- """Handle automatic (periodic) check for updates.
237
-
238
- Failures and no-update results are logged silently without
239
- showing the in-app error banner.
240
- """
241
- self._do_check_updates(silent=True)
242
-
243
- def _do_check_updates(self, *, silent: bool) -> None:
244
- """Run an update check.
245
-
246
- Args:
247
- silent: When ``True``, suppress the in-app error banner
248
- for failures and no-update results. The banner is
249
- always shown when an update *is* available.
250
- """
251
- if self._client.updater is None:
252
- if not silent:
253
- self._banner.show_error('Updater is not initialized.')
254
- return
255
-
256
- # Disable both the tray action and the settings button while checking
257
- self.update_action.setEnabled(False)
258
- self.update_action.setText('Checking for Updates...')
259
- self._settings_window.set_checking()
260
-
261
- self._update_task = asyncio.create_task(self._async_check_updates(silent=silent))
262
-
263
- async def _async_check_updates(self, *, silent: bool) -> None:
264
- """Run the update check coroutine and route results."""
265
- try:
266
- result = await check_for_update(self._client)
267
- self._on_update_check_finished(result, silent=silent)
268
- except Exception as exc:
269
- logger.exception('Update check failed')
270
- self._on_update_check_error(str(exc), silent=silent)
271
-
272
- def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None:
273
- """Handle update check completion."""
274
- self._reset_update_action()
275
- self._settings_window.reset_check_updates_button()
276
-
277
- if result is None:
278
- self._settings_window.set_update_status('Check failed')
279
- if not silent:
280
- self._banner.show_error('Failed to check for updates.')
281
- else:
282
- logger.warning('Automatic update check failed (no result)')
283
- return
284
-
285
- if result.error:
286
- self._settings_window.set_update_status(result.error)
287
- if not silent:
288
- self._banner.show_error(result.error)
289
- else:
290
- logger.warning('Automatic update check failed: %s', result.error)
291
- return
292
-
293
- if not result.available:
294
- self._settings_window.set_update_status(
295
- f'Up to date ({result.current_version})',
296
- )
297
- if not silent:
298
- logger.info('No updates available (current: %s)', result.current_version)
299
- else:
300
- logger.debug('Automatic update check: no update available')
301
- return
302
-
303
- # Update available — show banner and start download automatically
304
- version = str(result.latest_version)
305
- self._settings_window.set_update_status(f'Update available: {version}')
306
- self._banner.show_downloading(version)
307
- self._start_download(version)
308
-
309
- def _on_update_check_error(self, error: str, *, silent: bool = False) -> None:
310
- """Handle update check error."""
311
- self._reset_update_action()
312
- self._settings_window.reset_check_updates_button()
313
- self._settings_window.set_update_status(f'Error: {error}')
314
-
315
- if not silent:
316
- self._banner.show_error(f'Update check error: {error}')
317
- else:
318
- logger.warning('Automatic update check error: %s', error)
319
194
 
320
195
  # -- Tool update helpers --
321
196
 
@@ -571,51 +446,3 @@ class TrayScreen:
571
446
  tools_view.refresh()
572
447
 
573
448
  self._window.show()
574
-
575
- # -- Self-update download & apply --
576
-
577
- def _start_download(self, version: str) -> None:
578
- """Start downloading the update in the background.
579
-
580
- Args:
581
- version: The version string being downloaded (for banner display).
582
- """
583
- self._update_task = asyncio.create_task(self._async_download(version))
584
-
585
- async def _async_download(self, version: str) -> None:
586
- """Run the download coroutine and route results."""
587
- try:
588
- success = await download_update(
589
- self._client,
590
- on_progress=self._banner.show_downloading_progress,
591
- )
592
- self._on_download_finished(success, version)
593
- except Exception as exc:
594
- logger.exception('Update download failed')
595
- self._on_download_error(str(exc))
596
-
597
- def _on_download_finished(self, success: bool, version: str) -> None:
598
- """Handle download completion — transition banner to ready state."""
599
- if not success:
600
- self._banner.show_error('Download failed. Please try again later.')
601
- return
602
-
603
- self._banner.show_ready(version)
604
- self._settings_window.set_update_status(f'Ready to install: {version}')
605
-
606
- def _on_download_error(self, error: str) -> None:
607
- """Handle download error — show error banner."""
608
- self._banner.show_error(f'Download error: {error}')
609
-
610
- def _apply_update(self) -> None:
611
- """Apply the downloaded update and restart."""
612
- if self._client.updater is None:
613
- return
614
-
615
- try:
616
- self._client.apply_update_on_exit(restart=True)
617
- logger.info('Update scheduled — restarting application')
618
- self._app.quit()
619
- except Exception as e:
620
- logger.error('Failed to apply update: %s', e)
621
- self._banner.show_error(f'Failed to apply update: {e}')
@@ -436,6 +436,21 @@ SETTINGS_GEAR_STYLE = (
436
436
  )
437
437
  """Gear button style for the MainWindow tab corner widget."""
438
438
 
439
+ # ---------------------------------------------------------------------------
440
+ # Settings inline update-status colours
441
+ # ---------------------------------------------------------------------------
442
+ UPDATE_STATUS_UP_TO_DATE_STYLE = 'color: #89d185; font-size: 12px;'
443
+ """Green text for 'Up to date' / 'Ready' status."""
444
+
445
+ UPDATE_STATUS_AVAILABLE_STYLE = 'color: #cca700; font-size: 12px;'
446
+ """Orange text for 'Update available' status."""
447
+
448
+ UPDATE_STATUS_ERROR_STYLE = 'color: #f48771; font-size: 12px;'
449
+ """Red text for error / check-failed status."""
450
+
451
+ UPDATE_STATUS_CHECKING_STYLE = 'color: #808080; font-size: 12px; font-style: italic;'
452
+ """Grey italic text for 'Checking…' status."""
453
+
439
454
  # ---------------------------------------------------------------------------
440
455
  # Update banner (in-app self-update notification)
441
456
  # ---------------------------------------------------------------------------