synodic-client 0.0.1.dev84__tar.gz → 0.0.1.dev86__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 (106) hide show
  1. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/tool_update_controller.py +1 -3
  4. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/tray.py +3 -0
  5. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/update_controller.py +13 -16
  6. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/config.py +6 -1
  7. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/resolution.py +6 -11
  8. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/schema.py +47 -1
  9. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/updater.py +12 -11
  10. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_install.py +1 -0
  11. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/conftest.py +1 -0
  12. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_controller.py +73 -0
  13. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_feedback.py +2 -2
  14. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_config.py +99 -0
  15. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_updater.py +445 -70
  16. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/LICENSE.md +0 -0
  17. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/README.md +0 -0
  18. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/__init__.py +0 -0
  19. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/__main__.py +0 -0
  20. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/bootstrap.py +0 -0
  22. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/config_store.py +0 -0
  23. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/data.py +0 -0
  24. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/debug.py +0 -0
  25. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/icon.py +0 -0
  26. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/init.py +0 -0
  27. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/instance.py +0 -0
  28. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/package_state.py +0 -0
  29. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/qt.py +0 -0
  30. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/schema.py +0 -0
  31. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/__init__.py +0 -0
  32. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/action_card.py +0 -0
  33. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/card.py +0 -0
  34. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/install.py +0 -0
  35. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/install_workers.py +0 -0
  36. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/log_panel.py +0 -0
  37. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/plugin_row.py +0 -0
  38. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/projects.py +0 -0
  39. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/schema.py +0 -0
  40. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/screen.py +0 -0
  41. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/settings.py +0 -0
  42. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/sidebar.py +0 -0
  43. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/spinner.py +0 -0
  44. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/update_banner.py +0 -0
  45. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/wsl.py +0 -0
  46. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/theme.py +0 -0
  47. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/update_model.py +0 -0
  48. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/uri.py +0 -0
  49. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/__init__.py +0 -0
  50. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/config.py +0 -0
  51. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/context.py +0 -0
  52. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/debug.py +0 -0
  53. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/install.py +0 -0
  54. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/output.py +0 -0
  55. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/project.py +0 -0
  56. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/tool.py +0 -0
  57. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/update.py +0 -0
  58. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/client.py +0 -0
  59. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/logging.py +0 -0
  60. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/__init__.py +0 -0
  61. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/bootstrap.py +0 -0
  62. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/config.py +0 -0
  63. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/install.py +0 -0
  64. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/project.py +0 -0
  65. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/schema.py +0 -0
  66. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/tool.py +0 -0
  67. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/update.py +0 -0
  68. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/protocol.py +0 -0
  69. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/py.typed +0 -0
  70. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/startup.py +0 -0
  71. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/subprocess_patch.py +0 -0
  72. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/__init__.py +0 -0
  73. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/conftest.py +0 -0
  74. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/__init__.py +0 -0
  75. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/__init__.py +0 -0
  76. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_config.py +0 -0
  77. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_install_plan.py +0 -0
  78. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_project.py +0 -0
  79. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_tool.py +0 -0
  80. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_update.py +0 -0
  81. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/__init__.py +0 -0
  82. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_action_card.py +0 -0
  83. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_gather_packages.py +0 -0
  84. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_install_preview.py +0 -0
  85. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_log_panel.py +0 -0
  86. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_logging.py +0 -0
  87. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_package_state.py +0 -0
  88. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_preview_model.py +0 -0
  89. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_settings.py +0 -0
  90. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_sidebar.py +0 -0
  91. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_tray_window_show.py +0 -0
  92. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_banner.py +0 -0
  93. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_bootstrap.py +0 -0
  94. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_cli.py +0 -0
  95. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_client_updater.py +0 -0
  96. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_client_version.py +0 -0
  97. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_examples.py +0 -0
  98. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_init.py +0 -0
  99. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_install.py +0 -0
  100. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_resolution.py +0 -0
  101. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_uri.py +0 -0
  102. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_workers.py +0 -0
  103. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/__init__.py +0 -0
  104. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/conftest.py +0 -0
  105. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/test_protocol.py +0 -0
  106. {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/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.dev84
3
+ Version: 0.0.1.dev86
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.1535.dev45597",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev84"
18
+ version = "0.0.1.dev86"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -29,9 +29,7 @@ from synodic_client.operations.tool import (
29
29
  update_all_tools,
30
30
  update_tool,
31
31
  )
32
- from synodic_client.resolution import (
33
- resolve_update_config,
34
- )
32
+ from synodic_client.resolution import resolve_update_config
35
33
 
36
34
  if TYPE_CHECKING:
37
35
  from synodic_client.application.config_store import ConfigStore
@@ -55,6 +55,9 @@ class TrayScreen:
55
55
  self.tray.activated.connect(self._on_tray_activated)
56
56
 
57
57
  self._build_menu()
58
+ # setContextMenu delegates positioning to the OS tray API, which
59
+ # correctly avoids the taskbar. Do not manually handle the Context
60
+ # activation reason — that causes duplicate popups or z-order issues.
58
61
  self.tray.setContextMenu(self._menu)
59
62
 
60
63
  # At early Windows login the notification area may not be ready.
@@ -31,10 +31,8 @@ from synodic_client.application.theme import (
31
31
  from synodic_client.application.update_model import UpdateModel
32
32
  from synodic_client.operations.schema import UpdateCheckResult
33
33
  from synodic_client.operations.update import apply_self_update, check_self_update, download_self_update
34
- from synodic_client.resolution import (
35
- ResolvedConfig,
36
- resolve_update_config,
37
- )
34
+ from synodic_client.resolution import resolve_update_config
35
+ from synodic_client.schema import ResolvedConfig, UpdateState
38
36
  from synodic_client.startup import sync_startup
39
37
 
40
38
  if TYPE_CHECKING:
@@ -93,7 +91,7 @@ class UpdateController:
93
91
 
94
92
  # Track update-relevant config fields to avoid reinitialising
95
93
  # on every config save (e.g. timestamp-only changes).
96
- self._update_config_key = self._extract_update_key(store.config)
94
+ self._update_config_key = resolve_update_config(store.config)
97
95
 
98
96
  # Periodic auto-update timer
99
97
  self._auto_update_timer: QTimer | None = None
@@ -225,7 +223,7 @@ class UpdateController:
225
223
  return
226
224
  self._auto_apply = config.auto_apply
227
225
 
228
- new_key = self._extract_update_key(config)
226
+ new_key = resolve_update_config(config)
229
227
  if new_key == self._update_config_key:
230
228
  return
231
229
  self._update_config_key = new_key
@@ -237,16 +235,6 @@ class UpdateController:
237
235
  # Updater re-initialisation
238
236
  # ------------------------------------------------------------------
239
237
 
240
- @staticmethod
241
- def _extract_update_key(config: ResolvedConfig) -> tuple[object, ...]:
242
- """Return a hashable tuple of the fields that affect the updater."""
243
- return (
244
- config.update_source,
245
- config.update_channel,
246
- config.auto_update_interval_minutes,
247
- config.auto_apply,
248
- )
249
-
250
238
  def _reinitialize_updater(self, config: ResolvedConfig) -> None:
251
239
  """Re-derive update settings and restart the updater and timer.
252
240
 
@@ -289,6 +277,15 @@ class UpdateController:
289
277
  self._model.set_error('Updater is not initialized.')
290
278
  return
291
279
 
280
+ # A download may still be running in a thread-pool executor even
281
+ # after the asyncio Task wrapper has been cancelled. Starting a
282
+ # new check would trigger a second concurrent download for the
283
+ # same package, leading to a file-rename race on Windows.
284
+ if self._client.updater.state == UpdateState.DOWNLOADING:
285
+ logger.debug('Skipping update check — download already in progress')
286
+ self._model.set_check_button_enabled(True)
287
+ return
288
+
292
289
  # Always disable the button; only show "Checking…" when no
293
290
  # download is already pending (to preserve the ready state).
294
291
  self._model.set_check_button_enabled(False)
@@ -122,11 +122,16 @@ def load_user_config() -> UserConfig:
122
122
 
123
123
  try:
124
124
  data = json.loads(path.read_text(encoding='utf-8'))
125
+ except json.JSONDecodeError, OSError:
126
+ logger.exception('Failed to read config from %s, using defaults', path)
127
+ return UserConfig()
128
+
129
+ try:
125
130
  config = UserConfig.model_validate(data)
126
131
  logger.debug('Loaded user config from %s', path)
127
132
  return config
128
133
  except Exception:
129
- logger.exception('Failed to load user config from %s, using defaults', path)
134
+ logger.exception('Failed to validate user config from %s, using defaults', path)
130
135
  return UserConfig()
131
136
 
132
137
 
@@ -25,9 +25,7 @@ from synodic_client.config import (
25
25
  from synodic_client.schema import (
26
26
  DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
27
27
  DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
28
- GITHUB_REPO_URL,
29
28
  ResolvedConfig,
30
- UpdateChannel,
31
29
  UpdateConfig,
32
30
  UserConfig,
33
31
  )
@@ -160,7 +158,11 @@ def update_user_config(**changes: object) -> ResolvedConfig:
160
158
 
161
159
 
162
160
  def resolve_update_config(config: ResolvedConfig) -> UpdateConfig:
163
- """Derive an ``UpdateConfig`` from resolved configuration values.
161
+ """Derive an :class:`UpdateConfig` from resolved configuration values.
162
+
163
+ Delegates to :meth:`UpdateConfig.from_resolved` so the type owns
164
+ its own construction while this module stays the canonical entry
165
+ point for all resolution logic.
164
166
 
165
167
  Args:
166
168
  config: A resolved configuration snapshot.
@@ -168,11 +170,4 @@ def resolve_update_config(config: ResolvedConfig) -> UpdateConfig:
168
170
  Returns:
169
171
  An ``UpdateConfig`` ready to initialise the updater.
170
172
  """
171
- channel = UpdateChannel.DEVELOPMENT if config.update_channel == 'dev' else UpdateChannel.STABLE
172
-
173
- return UpdateConfig(
174
- channel=channel,
175
- repo_url=config.update_source or GITHUB_REPO_URL,
176
- auto_update_interval_minutes=config.auto_update_interval_minutes,
177
- tool_update_interval_minutes=config.tool_update_interval_minutes,
178
- )
173
+ return UpdateConfig.from_resolved(config)
@@ -8,13 +8,16 @@ that every layer can import them without circular dependencies.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  import sys
12
13
  from dataclasses import dataclass, field
13
14
  from enum import Enum, StrEnum, auto
14
15
  from typing import Any
15
16
 
16
17
  from packaging.version import Version
17
- from pydantic import BaseModel
18
+ from pydantic import BaseModel, ValidationError, model_validator
19
+
20
+ logger = logging.getLogger(__name__)
18
21
 
19
22
  # ---------------------------------------------------------------------------
20
23
  # BuildConfig — read-only, lives next to the executable
@@ -109,6 +112,31 @@ class UserConfig(BaseModel):
109
112
  # tool updates have been recorded.
110
113
  last_tool_updates: dict[str, str] | None = None
111
114
 
115
+ @model_validator(mode='wrap')
116
+ @classmethod
117
+ def _recover_invalid_fields(cls, data: Any, handler: Any) -> UserConfig:
118
+ """Silently drop fields that fail validation instead of rejecting the entire config.
119
+
120
+ When an on-disk ``config.json`` contains a value whose type no longer
121
+ matches the schema (e.g. after a version upgrade renames or re-types a
122
+ field), the normal behaviour is to raise ``ValidationError`` and lose
123
+ *every* setting. This wrap validator intercepts the error, removes
124
+ only the offending fields (so their defaults kick in), and retries.
125
+ """
126
+ try:
127
+ return handler(data)
128
+ except ValidationError as exc:
129
+ if not isinstance(data, dict):
130
+ raise
131
+
132
+ bad_fields = {str(e['loc'][0]) for e in exc.errors() if e.get('loc')}
133
+ cleaned = {k: v for k, v in data.items() if k not in bad_fields}
134
+
135
+ for name in bad_fields:
136
+ logger.warning('Discarding invalid config field %r (using default)', name)
137
+
138
+ return handler(cleaned)
139
+
112
140
 
113
141
  # ---------------------------------------------------------------------------
114
142
  # Update channel & state enums
@@ -201,6 +229,24 @@ class UpdateConfig:
201
229
  # Interval in minutes between tool update checks (0 = disabled)
202
230
  tool_update_interval_minutes: int = DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES
203
231
 
232
+ @classmethod
233
+ def from_resolved(cls, config: ResolvedConfig) -> UpdateConfig:
234
+ """Derive an ``UpdateConfig`` from resolved configuration values.
235
+
236
+ Args:
237
+ config: A resolved configuration snapshot.
238
+
239
+ Returns:
240
+ An ``UpdateConfig`` ready to initialise the updater.
241
+ """
242
+ channel = UpdateChannel.DEVELOPMENT if config.update_channel == 'dev' else UpdateChannel.STABLE
243
+ return cls(
244
+ channel=channel,
245
+ repo_url=config.update_source or GITHUB_REPO_URL,
246
+ auto_update_interval_minutes=config.auto_update_interval_minutes,
247
+ tool_update_interval_minutes=config.tool_update_interval_minutes,
248
+ )
249
+
204
250
  @property
205
251
  def channel_name(self) -> str:
206
252
  """Get the channel name for Velopack.
@@ -208,7 +208,12 @@ class Updater:
208
208
  # moved past it. A periodic re-check that discovers the
209
209
  # same release must not regress DOWNLOADED → UPDATE_AVAILABLE,
210
210
  # which would cause apply_update_on_exit() to reject the update.
211
- if self._state not in {UpdateState.DOWNLOADED, UpdateState.APPLYING, UpdateState.APPLIED}:
211
+ if self._state not in {
212
+ UpdateState.DOWNLOADING,
213
+ UpdateState.DOWNLOADED,
214
+ UpdateState.APPLYING,
215
+ UpdateState.APPLIED,
216
+ }:
212
217
  self._state = UpdateState.UPDATE_AVAILABLE
213
218
  logger.info('Update available: %s -> %s', self._current_version, latest)
214
219
  else:
@@ -379,20 +384,16 @@ class Updater:
379
384
  # Verify checksum — prefer SHA256, fall back to SHA1.
380
385
  if asset.SHA256:
381
386
  actual = sha256_hash.hexdigest()
382
- if not actual.lower() == asset.SHA256.lower():
387
+ if actual.lower() != asset.SHA256.lower():
383
388
  partial_file.unlink(missing_ok=True)
384
- raise RuntimeError(
385
- f'SHA256 mismatch for {asset.FileName}: expected {asset.SHA256}, got {actual}'
386
- )
389
+ raise RuntimeError(f'SHA256 mismatch for {asset.FileName}: expected {asset.SHA256}, got {actual}')
387
390
  elif asset.SHA1:
388
391
  actual_sha1 = hashlib.sha1(partial_file.read_bytes()).hexdigest() # noqa: S324 — verifying known digest
389
- if not actual_sha1.lower() == asset.SHA1.lower():
392
+ if actual_sha1.lower() != asset.SHA1.lower():
390
393
  partial_file.unlink(missing_ok=True)
391
- raise RuntimeError(
392
- f'SHA1 mismatch for {asset.FileName}: expected {asset.SHA1}, got {actual_sha1}'
393
- )
394
+ raise RuntimeError(f'SHA1 mismatch for {asset.FileName}: expected {asset.SHA1}, got {actual_sha1}')
394
395
 
395
- partial_file.rename(target_file)
396
+ partial_file.replace(target_file)
396
397
  logger.info('Direct download complete: %s', target_file)
397
398
 
398
399
  def download_update(self, progress_callback: Callable[[int], None] | None = None) -> bool:
@@ -545,7 +546,7 @@ class Updater:
545
546
  raise RuntimeError(f'Failed to create Velopack UpdateManager: {e}') from e
546
547
 
547
548
 
548
- def _on_before_uninstall(version: str) -> None:
549
+ def on_before_uninstall(version: str) -> None:
549
550
  """Velopack hook: called before the app is uninstalled.
550
551
 
551
552
  Removes the ``synodic://`` URI protocol handler and auto-startup
@@ -61,6 +61,7 @@ def _make_action(
61
61
  action.package.constraint = constraint
62
62
  else:
63
63
  action.package = None
64
+ action.distro = None
64
65
  return action
65
66
 
66
67
 
@@ -97,6 +97,7 @@ def make_action(
97
97
  action.command = overrides.get('command')
98
98
  action.include_prereleases = overrides.get('include_prereleases', False)
99
99
  action.plugin_target = overrides.get('plugin_target')
100
+ action.distro = overrides.get('distro')
100
101
  return action
101
102
 
102
103
 
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from unittest.mock import MagicMock, patch
6
6
 
7
+ import pytest
8
+
7
9
  from synodic_client.application.screen.update_banner import UpdateBanner
8
10
  from synodic_client.application.theme import (
9
11
  UPDATE_STATUS_AVAILABLE_STYLE,
@@ -13,6 +15,7 @@ from synodic_client.application.theme import (
13
15
  from synodic_client.application.update_controller import UpdateController
14
16
  from synodic_client.application.update_model import UpdateModel
15
17
  from synodic_client.operations.schema import UpdateCheckResult
18
+ from synodic_client.schema import UpdateState
16
19
 
17
20
  from .conftest import make_config_store, make_resolved_config
18
21
 
@@ -576,3 +579,73 @@ class TestApplyGuard:
576
579
  assert banner.state.name == 'HIDDEN'
577
580
  client.apply_update_on_exit.assert_not_called()
578
581
  app.quit.assert_not_called()
582
+
583
+
584
+ # ---------------------------------------------------------------------------
585
+ # DOWNLOADING guard in _do_check
586
+ # ---------------------------------------------------------------------------
587
+
588
+
589
+ class TestDoCheckDownloadingGuard:
590
+ """Verify _do_check skips checks when a download is in progress."""
591
+
592
+ @staticmethod
593
+ @pytest.mark.parametrize('silent', [False, True], ids=['manual', 'silent'])
594
+ def test_check_skipped_when_downloading(silent: bool) -> None:
595
+ """_do_check should return early without creating a task when DOWNLOADING."""
596
+ ctrl, _app, client, banner, model = _make_controller()
597
+ spy = ModelSpy(model)
598
+ client.updater.state = UpdateState.DOWNLOADING
599
+
600
+ with patch.object(ctrl, '_set_task') as mock_set_task:
601
+ ctrl._do_check(silent=silent)
602
+
603
+ mock_set_task.assert_not_called()
604
+ # Button should be re-enabled
605
+ assert True in spy.check_button_enabled
606
+
607
+ @staticmethod
608
+ def test_check_proceeds_when_not_downloading() -> None:
609
+ """_do_check should proceed normally when updater is not DOWNLOADING."""
610
+ ctrl, _app, client, banner, model = _make_controller()
611
+ client.updater.state = UpdateState.NO_UPDATE
612
+
613
+ with patch.object(ctrl, '_set_task') as mock_set_task:
614
+ ctrl._do_check(silent=False)
615
+
616
+ mock_set_task.assert_called_once()
617
+
618
+
619
+ # ---------------------------------------------------------------------------
620
+ # request_retry / request_apply
621
+ # ---------------------------------------------------------------------------
622
+
623
+
624
+ class TestRequestRetry:
625
+ """Verify request_retry clears the failed version and re-checks."""
626
+
627
+ @staticmethod
628
+ def test_clears_failed_version_and_checks() -> None:
629
+ """request_retry should clear _failed_version then trigger a check."""
630
+ ctrl, _app, _client, banner, model = _make_controller()
631
+ ctrl._failed_version = '2.0.0'
632
+
633
+ with patch.object(ctrl, 'check_now') as mock_check:
634
+ ctrl.request_retry()
635
+
636
+ assert ctrl._failed_version is None
637
+ mock_check.assert_called_once_with(silent=True)
638
+
639
+
640
+ class TestRequestApply:
641
+ """Verify request_apply delegates to _apply_update."""
642
+
643
+ @staticmethod
644
+ def test_delegates_to_apply_update() -> None:
645
+ """request_apply should call _apply_update(silent=False)."""
646
+ ctrl, _app, _client, banner, model = _make_controller()
647
+
648
+ with patch.object(ctrl, '_apply_update') as mock_apply:
649
+ ctrl.request_apply()
650
+
651
+ mock_apply.assert_called_once_with(silent=False)
@@ -512,9 +512,9 @@ class TestCompositeSignalKeys:
512
512
  header.set_runtime('3.11')
513
513
  spy = MagicMock()
514
514
  header.auto_update_toggled.connect(spy)
515
- # Find the Auto button and click it
515
+ # Find the auto-update toggle button (checkable button)
516
516
  for child in header.findChildren(QPushButton):
517
- if child.text() == 'Auto':
517
+ if child.isCheckable():
518
518
  child.click()
519
519
  break
520
520
  spy.assert_called_once()
@@ -10,6 +10,7 @@ from synodic_client.config import (
10
10
  BuildConfig,
11
11
  UserConfig,
12
12
  config_dir,
13
+ load_user_config,
13
14
  save_user_config,
14
15
  set_dev_mode,
15
16
  )
@@ -208,3 +209,101 @@ class TestSaveUserConfig:
208
209
  assert loaded.update_channel == 'dev'
209
210
  assert loaded.auto_start is False
210
211
  assert loaded.update_source is None
212
+
213
+
214
+ class TestUserConfigRecovery:
215
+ """Tests for the wrap validator that recovers invalid fields."""
216
+
217
+ @staticmethod
218
+ def test_single_corrupt_field_preserves_others() -> None:
219
+ """One invalid field is discarded; all other fields survive."""
220
+ data = {
221
+ 'update_channel': 'dev',
222
+ 'auto_start': False,
223
+ 'auto_update_interval_minutes': 'not_an_int', # corrupt
224
+ }
225
+ config = UserConfig.model_validate(data)
226
+ assert config.update_channel == 'dev'
227
+ assert config.auto_start is False
228
+ assert config.auto_update_interval_minutes is None # reset to default
229
+
230
+ @staticmethod
231
+ def test_multiple_corrupt_fields() -> None:
232
+ """Multiple invalid fields are discarded; valid fields survive."""
233
+ data = {
234
+ 'update_channel': 'stable',
235
+ 'auto_update_interval_minutes': 'bad',
236
+ 'auto_apply': 'not_a_bool',
237
+ 'debug_logging': 42, # int coerces to bool in Pydantic — this is valid
238
+ 'auto_start': False,
239
+ }
240
+ config = UserConfig.model_validate(data)
241
+ assert config.update_channel == 'stable'
242
+ assert config.auto_start is False
243
+ # The corrupt fields revert to defaults
244
+ assert config.auto_update_interval_minutes is None
245
+ assert config.auto_apply is None
246
+
247
+ @staticmethod
248
+ def test_all_fields_corrupt() -> None:
249
+ """When every known field is invalid, result is equivalent to UserConfig()."""
250
+ data = {
251
+ 'update_source': 123, # str field, int won't coerce
252
+ 'update_channel': [],
253
+ 'auto_update_interval_minutes': 'bad',
254
+ 'tool_update_interval_minutes': 'bad',
255
+ 'auto_apply': 'nope',
256
+ 'auto_start': 'nope',
257
+ 'debug_logging': 'nope',
258
+ 'plugin_auto_update': 'bad',
259
+ 'prerelease_packages': 42,
260
+ 'last_client_update': [],
261
+ 'last_tool_updates': 'bad',
262
+ }
263
+ config = UserConfig.model_validate(data)
264
+ assert config == UserConfig()
265
+
266
+ @staticmethod
267
+ def test_valid_data_unchanged() -> None:
268
+ """Fully valid data passes through the wrap validator without modification."""
269
+ data = {
270
+ 'update_channel': 'dev',
271
+ 'auto_start': True,
272
+ 'auto_update_interval_minutes': 10,
273
+ }
274
+ expected_interval = 10
275
+ config = UserConfig.model_validate(data)
276
+ assert config.update_channel == 'dev'
277
+ assert config.auto_start is True
278
+ assert config.auto_update_interval_minutes == expected_interval
279
+
280
+
281
+ class TestLoadUserConfigRecovery:
282
+ """Integration tests: load_user_config with corrupt files on disk."""
283
+
284
+ @staticmethod
285
+ def test_corrupt_field_recovered_from_disk(tmp_path: Path) -> None:
286
+ """A config file with one bad field loads with that field reset to default."""
287
+ config_data = {
288
+ 'update_channel': 'dev',
289
+ 'auto_start': False,
290
+ 'auto_update_interval_minutes': 'not_a_number',
291
+ }
292
+ (tmp_path / 'config.json').write_text(json.dumps(config_data), encoding='utf-8')
293
+
294
+ with patch('synodic_client.config.config_dir', return_value=tmp_path):
295
+ config = load_user_config()
296
+
297
+ assert config.update_channel == 'dev'
298
+ assert config.auto_start is False
299
+ assert config.auto_update_interval_minutes is None
300
+
301
+ @staticmethod
302
+ def test_invalid_json_returns_defaults(tmp_path: Path) -> None:
303
+ """Completely invalid JSON returns a default UserConfig."""
304
+ (tmp_path / 'config.json').write_text('{{not valid json', encoding='utf-8')
305
+
306
+ with patch('synodic_client.config.config_dir', return_value=tmp_path):
307
+ config = load_user_config()
308
+
309
+ assert config == UserConfig()