synodic-client 0.0.1.dev78__tar.gz → 0.0.1.dev79__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.dev79}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/bootstrap.py +16 -3
  4. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/update_controller.py +7 -1
  5. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/update.py +8 -1
  6. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/resolution.py +1 -7
  7. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/updater.py +102 -15
  8. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_update_controller.py +17 -0
  9. synodic_client-0.0.1.dev79/tests/unit/test_bootstrap.py +79 -0
  10. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_cli.py +28 -1
  11. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_resolution.py +4 -4
  12. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_updater.py +6 -6
  13. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/LICENSE.md +0 -0
  14. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/README.md +0 -0
  15. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/__init__.py +0 -0
  16. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/__main__.py +0 -0
  17. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/__init__.py +0 -0
  18. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/config_store.py +0 -0
  19. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/data.py +0 -0
  20. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/debug.py +0 -0
  21. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/icon.py +0 -0
  22. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/init.py +0 -0
  23. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/instance.py +0 -0
  24. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/package_state.py +0 -0
  25. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/qt.py +0 -0
  26. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/schema.py +0 -0
  27. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/action_card.py +0 -0
  29. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/card.py +0 -0
  30. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/install.py +0 -0
  31. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/install_workers.py +0 -0
  32. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/log_panel.py +0 -0
  33. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/plugin_row.py +0 -0
  34. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/projects.py +0 -0
  35. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/schema.py +0 -0
  36. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/screen.py +0 -0
  37. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/settings.py +0 -0
  38. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/sidebar.py +0 -0
  39. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/spinner.py +0 -0
  40. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/tool_update_controller.py +0 -0
  41. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/tray.py +0 -0
  42. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/screen/update_banner.py +0 -0
  43. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/theme.py +0 -0
  44. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/update_model.py +0 -0
  45. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/application/uri.py +0 -0
  46. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/__init__.py +0 -0
  47. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/config.py +0 -0
  48. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/context.py +0 -0
  49. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/debug.py +0 -0
  50. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/install.py +0 -0
  51. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/output.py +0 -0
  52. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/project.py +0 -0
  53. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/cli/tool.py +0 -0
  54. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/client.py +0 -0
  55. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/config.py +0 -0
  56. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/logging.py +0 -0
  57. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/__init__.py +0 -0
  58. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/bootstrap.py +0 -0
  59. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/config.py +0 -0
  60. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/install.py +0 -0
  61. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/project.py +0 -0
  62. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/schema.py +0 -0
  63. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/tool.py +0 -0
  64. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/operations/update.py +0 -0
  65. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/protocol.py +0 -0
  66. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/py.typed +0 -0
  67. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/schema.py +0 -0
  68. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/startup.py +0 -0
  69. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/synodic_client/subprocess_patch.py +0 -0
  70. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/__init__.py +0 -0
  71. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/conftest.py +0 -0
  72. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/__init__.py +0 -0
  73. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/__init__.py +0 -0
  74. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_config.py +0 -0
  75. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_install.py +0 -0
  76. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_install_plan.py +0 -0
  77. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_project.py +0 -0
  78. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_tool.py +0 -0
  79. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/operations/test_update.py +0 -0
  80. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/__init__.py +0 -0
  81. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/conftest.py +0 -0
  82. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_action_card.py +0 -0
  83. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_gather_packages.py +0 -0
  84. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_install_preview.py +0 -0
  85. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_log_panel.py +0 -0
  86. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_logging.py +0 -0
  87. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_package_state.py +0 -0
  88. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_preview_model.py +0 -0
  89. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_settings.py +0 -0
  90. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_sidebar.py +0 -0
  91. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_tray_window_show.py +0 -0
  92. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_update_banner.py +0 -0
  93. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/qt/test_update_feedback.py +0 -0
  94. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_client_updater.py +0 -0
  95. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_client_version.py +0 -0
  96. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_config.py +0 -0
  97. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_examples.py +0 -0
  98. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_init.py +0 -0
  99. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_install.py +0 -0
  100. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_uri.py +0 -0
  101. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/test_workers.py +0 -0
  102. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/windows/__init__.py +0 -0
  103. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/windows/conftest.py +0 -0
  104. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/tests/unit/windows/test_protocol.py +0 -0
  105. {synodic_client-0.0.1.dev78 → synodic_client-0.0.1.dev79}/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.dev79
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.dev79"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -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:
@@ -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