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.
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/pyproject.toml +1 -1
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/tool_update_controller.py +1 -3
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/tray.py +3 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/update_controller.py +13 -16
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/config.py +6 -1
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/resolution.py +6 -11
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/schema.py +47 -1
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/updater.py +12 -11
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_install.py +1 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/conftest.py +1 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_controller.py +73 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_feedback.py +2 -2
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_config.py +99 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_updater.py +445 -70
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/README.md +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/config_store.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/debug.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/package_state.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/qt.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/schema.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/install_workers.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/plugin_row.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/projects.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/schema.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/screen.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/settings.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/wsl.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/theme.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/update_model.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/config.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/context.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/debug.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/install.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/output.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/project.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/tool.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/cli/update.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/bootstrap.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/config.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/install.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/project.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/schema.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/tool.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/operations/update.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/subprocess_patch.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_config.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_install_plan.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_project.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_tool.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/operations/test_update.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_gather_packages.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_package_state.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_settings.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_bootstrap.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/windows/test_startup.py +0 -0
|
@@ -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
|
{synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
{synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_controller.py
RENAMED
|
@@ -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)
|
{synodic_client-0.0.1.dev84 → synodic_client-0.0.1.dev86}/tests/unit/qt/test_update_feedback.py
RENAMED
|
@@ -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
|
|
515
|
+
# Find the auto-update toggle button (checkable ↺ button)
|
|
516
516
|
for child in header.findChildren(QPushButton):
|
|
517
|
-
if child.
|
|
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()
|