synodic-client 0.0.1.dev78__tar.gz → 0.0.1.dev80__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 (105) hide show
  1. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/pyproject.toml +4 -4
  3. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/bootstrap.py +16 -3
  4. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/plugin_row.py +23 -3
  5. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/screen.py +5 -0
  6. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/tool_update_controller.py +1 -0
  7. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/theme.py +30 -13
  8. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/update_controller.py +7 -1
  9. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/update.py +8 -1
  10. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/resolution.py +1 -7
  11. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/updater.py +102 -15
  12. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_update_controller.py +17 -0
  13. synodic_client-0.0.1.dev80/tests/unit/test_bootstrap.py +79 -0
  14. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_cli.py +28 -1
  15. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_resolution.py +4 -4
  16. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_updater.py +6 -6
  17. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/LICENSE.md +0 -0
  18. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/README.md +0 -0
  19. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/__init__.py +0 -0
  20. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/__main__.py +0 -0
  21. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/__init__.py +0 -0
  22. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/config_store.py +0 -0
  23. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/data.py +0 -0
  24. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/debug.py +0 -0
  25. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/icon.py +0 -0
  26. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/init.py +0 -0
  27. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/instance.py +0 -0
  28. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/package_state.py +0 -0
  29. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/qt.py +0 -0
  30. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/schema.py +0 -0
  31. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/__init__.py +0 -0
  32. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/action_card.py +0 -0
  33. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/card.py +0 -0
  34. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/install.py +0 -0
  35. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/install_workers.py +0 -0
  36. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/log_panel.py +0 -0
  37. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/projects.py +0 -0
  38. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/schema.py +0 -0
  39. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/settings.py +0 -0
  40. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/sidebar.py +0 -0
  41. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/spinner.py +0 -0
  42. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/tray.py +0 -0
  43. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/screen/update_banner.py +0 -0
  44. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/update_model.py +0 -0
  45. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/application/uri.py +0 -0
  46. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/__init__.py +0 -0
  47. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/config.py +0 -0
  48. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/context.py +0 -0
  49. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/debug.py +0 -0
  50. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/install.py +0 -0
  51. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/output.py +0 -0
  52. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/project.py +0 -0
  53. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/cli/tool.py +0 -0
  54. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/client.py +0 -0
  55. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/config.py +0 -0
  56. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/logging.py +0 -0
  57. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/__init__.py +0 -0
  58. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/bootstrap.py +0 -0
  59. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/config.py +0 -0
  60. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/install.py +0 -0
  61. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/project.py +0 -0
  62. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/schema.py +0 -0
  63. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/tool.py +0 -0
  64. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/operations/update.py +0 -0
  65. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/protocol.py +0 -0
  66. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/py.typed +0 -0
  67. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/schema.py +0 -0
  68. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/startup.py +0 -0
  69. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/synodic_client/subprocess_patch.py +0 -0
  70. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/__init__.py +0 -0
  71. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/conftest.py +0 -0
  72. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/__init__.py +0 -0
  73. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/test_config.py +0 -0
  75. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/test_install.py +0 -0
  76. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/test_install_plan.py +0 -0
  77. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/test_project.py +0 -0
  78. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/test_tool.py +0 -0
  79. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/operations/test_update.py +0 -0
  80. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/__init__.py +0 -0
  81. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/conftest.py +0 -0
  82. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_action_card.py +0 -0
  83. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_gather_packages.py +0 -0
  84. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_install_preview.py +0 -0
  85. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_log_panel.py +0 -0
  86. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_logging.py +0 -0
  87. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_package_state.py +0 -0
  88. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_preview_model.py +0 -0
  89. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_settings.py +0 -0
  90. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_sidebar.py +0 -0
  91. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_tray_window_show.py +0 -0
  92. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_update_banner.py +0 -0
  93. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/qt/test_update_feedback.py +0 -0
  94. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_client_updater.py +0 -0
  95. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_client_version.py +0 -0
  96. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_config.py +0 -0
  97. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_examples.py +0 -0
  98. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_init.py +0 -0
  99. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_install.py +0 -0
  100. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_uri.py +0 -0
  101. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/test_workers.py +0 -0
  102. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/windows/__init__.py +0 -0
  103. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/windows/conftest.py +0 -0
  104. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/tests/unit/windows/test_protocol.py +0 -0
  105. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev80}/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.dev78
3
+ Version: 0.0.1.dev80
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.1521.dev61717",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev78"
18
+ version = "0.0.1.dev80"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -35,12 +35,12 @@ build = [
35
35
  "pyinstaller>=6.19.0",
36
36
  ]
37
37
  lint = [
38
- "ruff>=0.15.6",
39
- "pyrefly>=0.57.0",
38
+ "ruff>=0.15.7",
39
+ "pyrefly>=0.57.1",
40
40
  ]
41
41
  test = [
42
42
  "pytest>=9.0.2",
43
- "pytest-cov>=7.0.0",
43
+ "pytest-cov>=7.1.0",
44
44
  "pytest-mock>=3.15.1",
45
45
  ]
46
46
 
@@ -8,9 +8,11 @@ timeouts (15–30 s) and must complete before the process is killed.
8
8
  Import order matters:
9
9
  1. stdlib + config (pure-Python, fast)
10
10
  2. configure_logging() — now Qt-free
11
- 3. initialize_velopack() — hooks run with logging active
12
- 4. run_startup_preamble() protocol, config seed, auto-startup
13
- 5. import qt.application PySide6 / porringer loaded here
11
+ 3. sync_startup() — refresh Windows auto-startup registry **before**
12
+ Velopack, which may exit the process during post-update hooks
13
+ 4. initialize_velopack()hooks run with logging active
14
+ 5. run_startup_preamble() — protocol, config seed, auto-startup
15
+ 6. import qt.application — PySide6 / porringer loaded here
14
16
  """
15
17
 
16
18
  import logging
@@ -47,6 +49,17 @@ def bootstrap() -> None:
47
49
  logger = logging.getLogger(__name__)
48
50
  logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)
49
51
 
52
+ # Refresh the Windows auto-startup registry entry BEFORE Velopack
53
+ # initialisation. App.run() may exit the current process during
54
+ # post-update lifecycle hooks, so sync_startup must run first to
55
+ # ensure the registry path stays current after an update.
56
+ if not dev_mode:
57
+ from synodic_client.resolution import resolve_config
58
+ from synodic_client.startup import sync_startup
59
+
60
+ config = resolve_config()
61
+ sync_startup(sys.executable, auto_start=config.auto_start)
62
+
50
63
  initialize_velopack()
51
64
 
52
65
  if not dev_mode:
@@ -25,6 +25,7 @@ from synodic_client.application.screen.schema import PluginRowData, ProjectInsta
25
25
  from synodic_client.application.screen.spinner import SpinnerCanvas
26
26
  from synodic_client.application.theme import (
27
27
  FILTER_CHIP_STYLE,
28
+ PLUGIN_CHECK_STYLE,
28
29
  PLUGIN_KIND_HEADER_STYLE,
29
30
  PLUGIN_PROVIDER_NAME_STYLE,
30
31
  PLUGIN_PROVIDER_RUNTIME_TAG_DEFAULT_STYLE,
@@ -138,6 +139,9 @@ class PluginProviderHeader(QFrame):
138
139
  auto_update_toggled = Signal(str, bool)
139
140
  """Emitted with ``(plugin_name, enabled)`` when the auto-update toggle changes."""
140
141
 
142
+ check_requested = Signal(str)
143
+ """Emitted with the plugin name when the manual check-for-updates button is clicked."""
144
+
141
145
  update_requested = Signal(str)
142
146
  """Emitted with the plugin name when the per-plugin *Update* button is clicked."""
143
147
 
@@ -159,6 +163,7 @@ class PluginProviderHeader(QFrame):
159
163
  self._runtime_tag = ''
160
164
  self._signal_key = plugin.name
161
165
  self._update_btn: QPushButton | None = None
166
+ self._check_btn: QPushButton | None = None
162
167
  self._checking_spinner: _RowSpinner | None = None
163
168
 
164
169
  self._layout = QHBoxLayout(self)
@@ -230,8 +235,8 @@ class PluginProviderHeader(QFrame):
230
235
  auto_update: bool,
231
236
  has_updates: bool,
232
237
  ) -> None:
233
- """Build Auto/Update control buttons."""
234
- toggle_btn = QPushButton('Auto')
238
+ """Build auto-update toggle, check, and Update control buttons."""
239
+ toggle_btn = QPushButton('\u21ba')
235
240
  toggle_btn.setCheckable(True)
236
241
  toggle_btn.setChecked(auto_update)
237
242
  toggle_btn.setStyleSheet(PLUGIN_TOGGLE_STYLE)
@@ -241,6 +246,15 @@ class PluginProviderHeader(QFrame):
241
246
  )
242
247
  layout.addWidget(toggle_btn)
243
248
 
249
+ check_btn = QPushButton('\u27f3')
250
+ check_btn.setStyleSheet(PLUGIN_CHECK_STYLE)
251
+ check_btn.setToolTip('Check for updates now')
252
+ check_btn.clicked.connect(
253
+ lambda: self.check_requested.emit(self._signal_key),
254
+ )
255
+ self._check_btn = check_btn
256
+ layout.addWidget(check_btn)
257
+
244
258
  self._checking_spinner = _RowSpinner(self)
245
259
  layout.addWidget(self._checking_spinner)
246
260
 
@@ -258,6 +272,8 @@ class PluginProviderHeader(QFrame):
258
272
  toggle_btn.setEnabled(False)
259
273
  toggle_btn.setChecked(False)
260
274
  toggle_btn.setToolTip('Not installed \u2014 cannot auto-update')
275
+ check_btn.setEnabled(False)
276
+ check_btn.setToolTip('Not installed \u2014 cannot check for updates')
261
277
  update_btn.setEnabled(False)
262
278
  update_btn.setToolTip('Not installed \u2014 cannot update')
263
279
 
@@ -281,10 +297,14 @@ class PluginProviderHeader(QFrame):
281
297
  return
282
298
  if checking:
283
299
  self._checking_spinner.start()
300
+ if self._check_btn is not None:
301
+ self._check_btn.hide()
284
302
  if self._update_btn is not None:
285
303
  self._update_btn.hide()
286
304
  else:
287
305
  self._checking_spinner.stop()
306
+ if self._check_btn is not None:
307
+ self._check_btn.show()
288
308
 
289
309
  def set_error(self, message: str) -> None:
290
310
  """Show a transient inline error that auto-hides after ~5 seconds."""
@@ -443,7 +463,7 @@ class PluginRow(QFrame):
443
463
 
444
464
  def _build_toggle(self, layout: QHBoxLayout, data: PluginRowData) -> None:
445
465
  """Add the auto-update toggle button."""
446
- toggle_btn = QPushButton('Auto')
466
+ toggle_btn = QPushButton('\u21ba')
447
467
  toggle_btn.setCheckable(True)
448
468
  toggle_btn.setChecked(data.auto_update)
449
469
  toggle_btn.setStyleSheet(PLUGIN_ROW_TOGGLE_STYLE)
@@ -95,6 +95,9 @@ class ToolsView(QWidget):
95
95
  update_all_requested = Signal()
96
96
  """Emitted when the global *Update All* button is clicked."""
97
97
 
98
+ plugin_check_requested = Signal(str)
99
+ """Emitted with a plugin name when its manual check-for-updates button is clicked."""
100
+
98
101
  plugin_update_requested = Signal(str)
99
102
  """Emitted with a plugin name when its per-plugin *Update* button is clicked."""
100
103
 
@@ -495,6 +498,7 @@ class ToolsView(QWidget):
495
498
  )
496
499
  provider.set_runtime(rt.tag, label=tag_text)
497
500
  provider.auto_update_toggled.connect(self._on_auto_update_toggled)
501
+ provider.check_requested.connect(self.plugin_check_requested.emit)
498
502
  provider.update_requested.connect(self.plugin_update_requested.emit)
499
503
  self._insert_section_widget(provider)
500
504
 
@@ -558,6 +562,7 @@ class ToolsView(QWidget):
558
562
  parent=self._container,
559
563
  )
560
564
  provider.auto_update_toggled.connect(self._on_auto_update_toggled)
565
+ provider.check_requested.connect(self.plugin_check_requested.emit)
561
566
  provider.update_requested.connect(self.plugin_update_requested.emit)
562
567
  self._insert_section_widget(provider)
563
568
 
@@ -148,6 +148,7 @@ class ToolUpdateOrchestrator:
148
148
  def connect_tools_view(self, tools_view: ToolsView) -> None:
149
149
  """Wire ToolsView signals once the view is lazily created."""
150
150
  tools_view.update_all_requested.connect(self.on_tool_update)
151
+ tools_view.plugin_check_requested.connect(self.on_single_plugin_update)
151
152
  tools_view.plugin_update_requested.connect(self.on_single_plugin_update)
152
153
  tools_view.package_update_requested.connect(self.on_single_package_update)
153
154
  tools_view.package_remove_requested.connect(self.on_single_package_remove)
@@ -187,13 +187,16 @@ PLUGIN_ROW_HOST_STYLE = 'font-size: 11px; color: #808080;'
187
187
  """Host-tool annotation label (e.g. "→ pdm") for injected packages."""
188
188
 
189
189
  PLUGIN_ROW_TOGGLE_STYLE = (
190
- 'QPushButton { padding: 1px 4px; border: 1px solid palette(mid); border-radius: 2px;'
191
- ' font-size: 10px; min-width: 36px; max-width: 36px; }'
192
- 'QPushButton:checked { background: #89d185; color: black; }'
193
- 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }'
194
- 'QPushButton:checked:disabled { background: transparent; color: palette(mid); }'
190
+ 'QPushButton { padding: 0px; border: 1px solid palette(mid); border-radius: 11px;'
191
+ ' font-size: 14px; min-width: 22px; max-width: 22px; min-height: 22px; max-height: 22px;'
192
+ ' color: palette(mid); background: transparent; }'
193
+ 'QPushButton:hover { border-color: palette(light); color: palette(light); }'
194
+ 'QPushButton:checked { background: #89d185; color: black; border-color: #89d185; }'
195
+ 'QPushButton:checked:hover { background: #a0d896; border-color: #a0d896; }'
196
+ 'QPushButton:disabled { color: palette(dark); border-color: palette(dark); background: transparent; }'
197
+ 'QPushButton:checked:disabled { background: transparent; color: palette(dark); border-color: palette(dark); }'
195
198
  )
196
- """Small inline auto-update toggle for individual package rows."""
199
+ """Small circular auto-update toggle (↺ icon) for individual package rows."""
197
200
 
198
201
  PLUGIN_ROW_UPDATE_STYLE = (
199
202
  'QPushButton { padding: 1px 4px; border: 1px solid palette(mid); border-radius: 2px;'
@@ -345,14 +348,28 @@ FILTER_TOGGLE_ACTIVE_STYLE = (
345
348
  )
346
349
  """Filter toggle button style when an active filter is in effect."""
347
350
 
348
- # Retained from previous design auto-update & per-plugin update buttons
351
+ # Auto-update toggle button for plugin provider header rows circular, stateful
349
352
  PLUGIN_TOGGLE_STYLE = (
350
- 'QPushButton { padding: 2px 8px; border: 1px solid palette(mid); border-radius: 3px;'
351
- ' min-width: 60px; max-width: 60px; }'
352
- 'QPushButton:checked { background: #89d185; color: black; }'
353
- 'QPushButton:disabled { color: palette(mid); border-color: palette(mid); background: transparent; }'
354
- 'QPushButton:checked:disabled { background: transparent; color: palette(mid); }'
355
- )
353
+ 'QPushButton { padding: 0px; border: 1px solid palette(mid); border-radius: 12px;'
354
+ ' font-size: 15px; min-width: 24px; max-width: 24px; min-height: 24px; max-height: 24px;'
355
+ ' color: palette(mid); background: transparent; }'
356
+ 'QPushButton:hover { border-color: palette(light); color: palette(light); }'
357
+ 'QPushButton:checked { background: #89d185; color: black; border-color: #89d185; }'
358
+ 'QPushButton:checked:hover { background: #a0d896; border-color: #a0d896; }'
359
+ 'QPushButton:disabled { color: palette(dark); border-color: palette(dark); background: transparent; }'
360
+ 'QPushButton:checked:disabled { background: transparent; color: palette(dark); border-color: palette(dark); }'
361
+ )
362
+
363
+ # Manual check-for-updates button for plugin provider header rows — rect, one-shot action
364
+ PLUGIN_CHECK_STYLE = (
365
+ 'QPushButton { padding: 0px; border: 1px solid palette(mid); border-radius: 3px;'
366
+ ' font-size: 15px; min-width: 24px; max-width: 24px; min-height: 24px; max-height: 24px;'
367
+ ' color: palette(mid); background: transparent; }'
368
+ 'QPushButton:hover { border-color: #569cd6; color: #569cd6; }'
369
+ 'QPushButton:pressed { background: rgba(86, 156, 214, 0.15); }'
370
+ 'QPushButton:disabled { color: palette(dark); border-color: palette(dark); background: transparent; }'
371
+ )
372
+ """One-shot check-for-updates icon button used in the plugin provider header."""
356
373
 
357
374
  PLUGIN_UPDATE_STYLE = (
358
375
  'QPushButton { padding: 2px 8px; border: 1px solid palette(mid); border-radius: 3px;'
@@ -124,7 +124,13 @@ class UpdateController:
124
124
  """Cancel any in-flight task and start *coro* as the active task."""
125
125
  if self._update_task is not None and not self._update_task.done():
126
126
  self._update_task.cancel()
127
- self._update_task = asyncio.create_task(coro)
127
+ try:
128
+ self._update_task = asyncio.create_task(coro)
129
+ except RuntimeError:
130
+ # No running event loop yet (e.g. during early init).
131
+ # The periodic timer will retry once the loop is running.
132
+ coro.close()
133
+ logger.debug('Deferred update check — event loop not yet running')
128
134
 
129
135
  # ------------------------------------------------------------------
130
136
  # Config helpers
@@ -8,6 +8,7 @@ synodic-c update apply
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
+ import sys
11
12
  from typing import Annotated
12
13
 
13
14
  import typer
@@ -75,7 +76,13 @@ def update_apply(
75
76
  """Apply a downloaded self-update."""
76
77
  from synodic_client.cli.context import get_services
77
78
  from synodic_client.operations.update import apply_self_update
79
+ from synodic_client.startup import sync_startup
80
+
81
+ client, _, config = get_services()
82
+
83
+ # Refresh the Windows auto-startup registry entry before the update
84
+ # replaces the executable, so the path stays current.
85
+ sync_startup(sys.executable, auto_start=config.auto_start)
78
86
 
79
- client, _, _ = get_services()
80
87
  apply_self_update(client, restart=not no_restart, silent=silent)
81
88
  typer.echo('Update applied.')
@@ -31,7 +31,6 @@ from synodic_client.schema import (
31
31
  UpdateConfig,
32
32
  UserConfig,
33
33
  )
34
- from synodic_client.updater import github_release_asset_url
35
34
 
36
35
  logger = logging.getLogger(__name__)
37
36
 
@@ -171,14 +170,9 @@ def resolve_update_config(config: ResolvedConfig) -> UpdateConfig:
171
170
  """
172
171
  channel = UpdateChannel.DEVELOPMENT if config.update_channel == 'dev' else UpdateChannel.STABLE
173
172
 
174
- repo_url = github_release_asset_url(
175
- config.update_source or GITHUB_REPO_URL,
176
- channel,
177
- )
178
-
179
173
  return UpdateConfig(
180
174
  channel=channel,
181
- repo_url=repo_url,
175
+ repo_url=config.update_source or GITHUB_REPO_URL,
182
176
  auto_update_interval_minutes=config.auto_update_interval_minutes,
183
177
  tool_update_interval_minutes=config.tool_update_interval_minutes,
184
178
  )
@@ -8,8 +8,10 @@ For non-installed (development) environments, updates are not supported.
8
8
  """
9
9
 
10
10
  import contextlib
11
+ import json
11
12
  import logging
12
13
  import sys
14
+ import urllib.request
13
15
  from collections.abc import Callable
14
16
  from typing import Any
15
17
 
@@ -173,7 +175,19 @@ class Updater:
173
175
  error='Not installed via Velopack',
174
176
  )
175
177
 
176
- velopack_info = manager.check_for_updates()
178
+ try:
179
+ velopack_info = manager.check_for_updates()
180
+ except Exception as sdk_err:
181
+ if '404' in str(sdk_err):
182
+ logger.debug('SDK check failed with 404, trying manifest fallback: %s', sdk_err)
183
+ velopack_info = self._check_manifest_fallback()
184
+ else:
185
+ raise
186
+
187
+ if velopack_info is None:
188
+ # SDK returned no update; try the manual manifest fallback
189
+ # in case the SDK's GithubSource skipped prerelease entries.
190
+ velopack_info = self._check_manifest_fallback()
177
191
 
178
192
  if velopack_info is not None:
179
193
  latest = Version(velopack_info.TargetFullRelease.Version)
@@ -202,20 +216,6 @@ class Updater:
202
216
  return self._update_info
203
217
 
204
218
  except Exception as e:
205
- if '404' in str(e):
206
- channel = self._config.channel_name
207
- msg = (
208
- f"No releases found for the '{channel}' channel. "
209
- "Try switching to the 'Development' channel in Settings \u2192 Channel."
210
- )
211
- logger.debug('No releases for channel %s: %s', channel, e)
212
- self._state = UpdateState.NO_UPDATE
213
- return UpdateInfo(
214
- available=False,
215
- current_version=self._current_version,
216
- error=msg,
217
- )
218
-
219
219
  logger.exception('Failed to check for updates')
220
220
  self._state = UpdateState.FAILED
221
221
  return UpdateInfo(
@@ -224,6 +224,93 @@ class Updater:
224
224
  error=str(e),
225
225
  )
226
226
 
227
+ def _check_manifest_fallback(self) -> Any:
228
+ """Download the release manifest directly and check for updates.
229
+
230
+ The Velopack SDK's ``GithubSource`` handler cannot discover
231
+ updates from prerelease GitHub Releases. This fallback
232
+ downloads ``releases.{channel}.json`` via Python's stdlib and
233
+ constructs a ``velopack.UpdateInfo`` when a newer version
234
+ exists.
235
+
236
+ Returns:
237
+ A ``velopack.UpdateInfo`` if an update is available,
238
+ ``None`` otherwise.
239
+ """
240
+ asset_base = github_release_asset_url(self._config.repo_url, self._config.channel)
241
+ manifest_url = f'{asset_base}/releases.{self._config.channel_name}.json'
242
+ logger.debug('Manifest fallback: fetching %s', manifest_url)
243
+
244
+ try:
245
+ req = urllib.request.Request(manifest_url, headers={'User-Agent': 'synodic-client'})
246
+ with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 — URL is derived from a known repo constant
247
+ data = json.loads(resp.read())
248
+ except Exception:
249
+ logger.debug('Manifest fallback failed for %s', manifest_url, exc_info=True)
250
+ return None
251
+
252
+ current_semver = pep440_to_semver(str(self._current_version))
253
+ best: dict[str, Any] | None = None
254
+ best_ver: str | None = None
255
+
256
+ for asset in data.get('Assets', []):
257
+ if asset.get('Type') != 'Full':
258
+ continue
259
+ ver = asset.get('Version', '')
260
+ if not ver:
261
+ continue
262
+ # Simple semver comparison via packaging.version (accepts
263
+ # semver pre-release tags like ``0.1.0-dev.79``).
264
+ try:
265
+ if Version(ver) > Version(current_semver):
266
+ if best_ver is None or Version(ver) > Version(best_ver):
267
+ best = asset
268
+ best_ver = ver
269
+ except Exception:
270
+ continue
271
+
272
+ if best is None:
273
+ logger.debug('Manifest fallback: no newer version found')
274
+ return None
275
+
276
+ logger.debug('Manifest fallback: found %s', best_ver)
277
+
278
+ target = velopack.VelopackAsset(
279
+ PackageId=best['PackageId'],
280
+ Version=best['Version'],
281
+ Type=best['Type'],
282
+ FileName=best['FileName'],
283
+ SHA1=best.get('SHA1', ''),
284
+ SHA256=best.get('SHA256', ''),
285
+ Size=best.get('Size', 0),
286
+ NotesMarkdown='',
287
+ NotesHtml='',
288
+ )
289
+
290
+ # Collect matching delta assets for the same version.
291
+ deltas = []
292
+ for asset in data.get('Assets', []):
293
+ if asset.get('Type') == 'Delta' and asset.get('Version') == best['Version']:
294
+ deltas.append(
295
+ velopack.VelopackAsset(
296
+ PackageId=asset['PackageId'],
297
+ Version=asset['Version'],
298
+ Type=asset['Type'],
299
+ FileName=asset['FileName'],
300
+ SHA1=asset.get('SHA1', ''),
301
+ SHA256=asset.get('SHA256', ''),
302
+ Size=asset.get('Size', 0),
303
+ NotesMarkdown='',
304
+ NotesHtml='',
305
+ )
306
+ )
307
+
308
+ return velopack.UpdateInfo(
309
+ TargetFullRelease=target,
310
+ DeltasToTarget=deltas,
311
+ IsDowngrade=False,
312
+ )
313
+
227
314
  def download_update(self, progress_callback: Callable[[int], None] | None = None) -> bool:
228
315
  """Download the update.
229
316
 
@@ -40,6 +40,7 @@ class ModelSpy:
40
40
  def _make_controller(
41
41
  *,
42
42
  auto_apply: bool = True,
43
+ auto_start: bool = True,
43
44
  auto_update_interval_minutes: int = 0,
44
45
  is_user_active: bool = False,
45
46
  ) -> tuple[UpdateController, MagicMock, MagicMock, UpdateBanner, UpdateModel]:
@@ -49,6 +50,7 @@ def _make_controller(
49
50
  """
50
51
  config = make_resolved_config(
51
52
  auto_apply=auto_apply,
53
+ auto_start=auto_start,
52
54
  auto_update_interval_minutes=auto_update_interval_minutes,
53
55
  )
54
56
 
@@ -352,6 +354,21 @@ class TestApplyUpdate:
352
354
  client.apply_update_on_exit.assert_called_once()
353
355
  app.quit.assert_called_once()
354
356
 
357
+ @staticmethod
358
+ def test_apply_update_passes_auto_start_false_from_config() -> None:
359
+ """sync_startup receives auto_start=False when config says so."""
360
+ ctrl, app, client, banner, model = _make_controller(auto_start=False)
361
+ ctrl._pending_version = '2.0.0'
362
+
363
+ with (
364
+ patch('synodic_client.application.update_controller.sync_startup') as mock_sync,
365
+ patch('synodic_client.application.update_controller.sys') as mock_sys,
366
+ ):
367
+ mock_sys.executable = r'C:\app\synodic.exe'
368
+ ctrl._apply_update()
369
+
370
+ mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=False)
371
+
355
372
 
356
373
  # ---------------------------------------------------------------------------
357
374
  # Settings changed → immediate check
@@ -0,0 +1,79 @@
1
+ """Tests for the bootstrap entry point startup-sync ordering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ _MODULE = 'synodic_client.application.bootstrap'
10
+
11
+
12
+ def _run_bootstrap(*, argv: list[str]) -> None:
13
+ """Import (or reload) the bootstrap module, triggering ``bootstrap()``.
14
+
15
+ The module-level ``bootstrap()`` call runs on every import/reload,
16
+ so all patches must be in place before calling this.
17
+ """
18
+ # Ensure sys.argv is set for the bootstrap function
19
+ with patch.object(sys, 'argv', argv):
20
+ if _MODULE in sys.modules:
21
+ importlib.reload(sys.modules[_MODULE])
22
+ else:
23
+ importlib.import_module(_MODULE)
24
+
25
+
26
+ class TestBootstrapStartupSync:
27
+ """Verify sync_startup runs before initialize_velopack in bootstrap."""
28
+
29
+ @staticmethod
30
+ def test_sync_startup_called_before_velopack_init() -> None:
31
+ """sync_startup must execute before initialize_velopack.
32
+
33
+ Velopack's App.run() may exit the process during post-update
34
+ hooks, so the startup registry must already be refreshed.
35
+ """
36
+ call_order: list[str] = []
37
+
38
+ def _record_sync(*args: object, **kwargs: object) -> None:
39
+ call_order.append('sync_startup')
40
+
41
+ def _record_velopack() -> None:
42
+ call_order.append('initialize_velopack')
43
+
44
+ mock_config = MagicMock(auto_start=True)
45
+
46
+ with (
47
+ patch('synodic_client.config.set_dev_mode'),
48
+ patch('synodic_client.logging.configure_logging'),
49
+ patch('synodic_client.subprocess_patch.apply'),
50
+ patch('synodic_client.updater.initialize_velopack', side_effect=_record_velopack),
51
+ patch('synodic_client.resolution.resolve_config', return_value=mock_config),
52
+ patch('synodic_client.startup.sync_startup', side_effect=_record_sync) as mock_sync,
53
+ patch('synodic_client.application.init.run_startup_preamble'),
54
+ patch('synodic_client.application.qt.application'),
55
+ patch('synodic_client.protocol.extract_uri_from_args', return_value=None),
56
+ ):
57
+ _run_bootstrap(argv=[r'C:\app\synodic.exe'])
58
+
59
+ assert call_order == ['sync_startup', 'initialize_velopack']
60
+ mock_sync.assert_called_once()
61
+ assert mock_sync.call_args.kwargs['auto_start'] is True
62
+
63
+ @staticmethod
64
+ def test_sync_startup_skipped_in_dev_mode() -> None:
65
+ """sync_startup is not called when --dev flag is passed."""
66
+ with (
67
+ patch('synodic_client.config.set_dev_mode'),
68
+ patch('synodic_client.logging.configure_logging'),
69
+ patch('synodic_client.subprocess_patch.apply'),
70
+ patch('synodic_client.updater.initialize_velopack'),
71
+ patch('synodic_client.resolution.resolve_config') as mock_resolve,
72
+ patch('synodic_client.startup.sync_startup') as mock_sync,
73
+ patch('synodic_client.application.qt.application'),
74
+ patch('synodic_client.protocol.extract_uri_from_args', return_value=None),
75
+ ):
76
+ _run_bootstrap(argv=[r'C:\app\synodic.exe', '--dev'])
77
+
78
+ mock_resolve.assert_not_called()
79
+ mock_sync.assert_not_called()
@@ -295,15 +295,42 @@ class TestUpdateCli:
295
295
  @staticmethod
296
296
  def test_update_apply() -> None:
297
297
  """Update apply calls apply_self_update."""
298
+ mock_config = MagicMock(auto_start=True)
298
299
  with (
299
- patch('synodic_client.cli.context.get_services', return_value=(MagicMock(), None, None)),
300
+ patch('synodic_client.cli.context.get_services', return_value=(MagicMock(), None, mock_config)),
300
301
  patch('synodic_client.operations.update.apply_self_update') as mock_apply,
302
+ patch('synodic_client.startup.sync_startup'),
301
303
  ):
302
304
  result = runner.invoke(app, ['update', 'apply'])
303
305
  assert result.exit_code == 0
304
306
  assert 'applied' in result.output.lower()
305
307
  mock_apply.assert_called_once()
306
308
 
309
+ @staticmethod
310
+ def test_update_apply_calls_sync_startup_before_apply() -> None:
311
+ """sync_startup is called with the config's auto_start before apply_self_update."""
312
+ call_order: list[str] = []
313
+ mock_config = MagicMock(auto_start=False)
314
+
315
+ def _record_sync(*args: object, **kwargs: object) -> None:
316
+ call_order.append('sync_startup')
317
+
318
+ def _record_apply(*args: object, **kwargs: object) -> None:
319
+ call_order.append('apply_self_update')
320
+
321
+ with (
322
+ patch('synodic_client.cli.context.get_services', return_value=(MagicMock(), None, mock_config)),
323
+ patch('synodic_client.startup.sync_startup', side_effect=_record_sync) as mock_sync,
324
+ patch('synodic_client.operations.update.apply_self_update', side_effect=_record_apply),
325
+ ):
326
+ result = runner.invoke(app, ['update', 'apply'])
327
+ assert result.exit_code == 0
328
+
329
+ mock_sync.assert_called_once()
330
+ # auto_start=False should be forwarded from config
331
+ assert mock_sync.call_args.kwargs['auto_start'] is False
332
+ assert call_order == ['sync_startup', 'apply_self_update']
333
+
307
334
 
308
335
  # ---------------------------------------------------------------------------
309
336
  # Debug subcommands
@@ -364,17 +364,17 @@ class TestResolveUpdateConfig:
364
364
 
365
365
  @staticmethod
366
366
  def test_default_source_dev() -> None:
367
- """Verify default dev source uses GitHub download path with dev tag."""
367
+ """Verify default dev source uses raw GitHub repo URL."""
368
368
  config = _make_resolved(update_channel='dev')
369
369
  result = resolve_update_config(config)
370
- assert result.repo_url == f'{GITHUB_REPO_URL}/releases/download/dev'
370
+ assert result.repo_url == GITHUB_REPO_URL
371
371
 
372
372
  @staticmethod
373
373
  def test_default_source_stable() -> None:
374
- """Verify default stable source uses GitHub latest download path."""
374
+ """Verify default stable source uses raw GitHub repo URL."""
375
375
  config = _make_resolved(update_channel='stable')
376
376
  result = resolve_update_config(config)
377
- assert result.repo_url == f'{GITHUB_REPO_URL}/releases/latest/download'
377
+ assert result.repo_url == GITHUB_REPO_URL
378
378
 
379
379
  @staticmethod
380
380
  def test_default_auto_update_interval() -> None:
@@ -227,18 +227,18 @@ class TestUpdaterCheckForUpdate:
227
227
 
228
228
  @staticmethod
229
229
  def test_check_404_returns_friendly_message(updater: Updater) -> None:
230
- """Verify a 404 from GitHub returns a friendly no-releases message."""
230
+ """Verify a 404 from GitHub falls back to manifest check gracefully."""
231
231
  mock_manager = MagicMock(spec=velopack.UpdateManager)
232
232
  mock_manager.check_for_updates.side_effect = RuntimeError('Network error: Http error: http status: 404')
233
233
 
234
- with patch.object(updater, '_get_velopack_manager', return_value=mock_manager):
234
+ with (
235
+ patch.object(updater, '_get_velopack_manager', return_value=mock_manager),
236
+ patch.object(updater, '_check_manifest_fallback', return_value=None),
237
+ ):
235
238
  info = updater.check_for_update()
236
239
 
237
240
  assert info.available is False
238
- assert info.error is not None
239
- assert 'No releases found' in info.error
240
- assert updater._config.channel_name in info.error
241
- # A missing channel is informational, not a hard failure
241
+ # Fallback returned None, so no error just no update.
242
242
  assert updater.state == UpdateState.NO_UPDATE
243
243
 
244
244
  @staticmethod