synodic-client 0.0.1.dev67__tar.gz → 0.0.1.dev69__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 (80) hide show
  1. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/pyproject.toml +2 -2
  3. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/bootstrap.py +22 -6
  4. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/init.py +2 -6
  5. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/settings.py +2 -6
  6. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/tray.py +38 -1
  7. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/update_controller.py +10 -0
  8. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/startup.py +57 -0
  9. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_settings.py +10 -14
  10. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_update_controller.py +16 -20
  11. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_init.py +17 -25
  12. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/windows/test_startup.py +125 -0
  13. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/LICENSE.md +0 -0
  14. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/README.md +0 -0
  15. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/__init__.py +0 -0
  16. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/__main__.py +0 -0
  17. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/__init__.py +0 -0
  18. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/config_store.py +0 -0
  19. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/data.py +0 -0
  20. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/icon.py +0 -0
  21. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/instance.py +0 -0
  22. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/package_state.py +0 -0
  23. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/qt.py +0 -0
  24. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/schema.py +0 -0
  25. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/__init__.py +0 -0
  26. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/action_card.py +0 -0
  27. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/card.py +0 -0
  28. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/install.py +0 -0
  29. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/install_workers.py +0 -0
  30. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/log_panel.py +0 -0
  31. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/plugin_row.py +0 -0
  32. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/projects.py +0 -0
  33. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/schema.py +0 -0
  34. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/screen.py +0 -0
  35. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/sidebar.py +0 -0
  36. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/spinner.py +0 -0
  37. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/tool_update_controller.py +0 -0
  38. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/screen/update_banner.py +0 -0
  39. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/theme.py +0 -0
  40. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/update_model.py +0 -0
  41. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/uri.py +0 -0
  42. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/application/workers.py +0 -0
  43. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/cli.py +0 -0
  44. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/client.py +0 -0
  45. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/config.py +0 -0
  46. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/logging.py +0 -0
  47. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/protocol.py +0 -0
  48. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/py.typed +0 -0
  49. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/resolution.py +0 -0
  50. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/schema.py +0 -0
  51. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/subprocess_patch.py +0 -0
  52. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/synodic_client/updater.py +0 -0
  53. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/__init__.py +0 -0
  54. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/conftest.py +0 -0
  55. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/__init__.py +0 -0
  56. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/conftest.py +0 -0
  58. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_action_card.py +0 -0
  59. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_gather_packages.py +0 -0
  60. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_install_preview.py +0 -0
  61. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_log_panel.py +0 -0
  62. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_logging.py +0 -0
  63. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_preview_model.py +0 -0
  64. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_sidebar.py +0 -0
  65. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_tray_window_show.py +0 -0
  66. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_update_banner.py +0 -0
  67. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/qt/test_update_feedback.py +0 -0
  68. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_cli.py +0 -0
  69. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_client_updater.py +0 -0
  70. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_client_version.py +0 -0
  71. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_config.py +0 -0
  72. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_examples.py +0 -0
  73. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_install.py +0 -0
  74. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_resolution.py +0 -0
  75. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_updater.py +0 -0
  76. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_uri.py +0 -0
  77. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/test_workers.py +0 -0
  78. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/windows/__init__.py +0 -0
  79. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/windows/conftest.py +0 -0
  80. {synodic_client-0.0.1.dev67 → synodic_client-0.0.1.dev69}/tests/unit/windows/test_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev67
3
+ Version: 0.0.1.dev69
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
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev79
11
+ Requires-Dist: porringer>=0.2.1.dev80
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1444.dev49733
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev79",
13
+ "porringer>=0.2.1.dev80",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev67"
18
+ version = "0.0.1.dev69"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -13,13 +13,25 @@ Import order matters:
13
13
  5. import qt.application — PySide6 / porringer loaded here
14
14
  """
15
15
 
16
+ import logging
16
17
  import sys
17
-
18
- from synodic_client.config import set_dev_mode
19
- from synodic_client.logging import configure_logging
20
- from synodic_client.protocol import extract_uri_from_args
21
- from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
22
- from synodic_client.updater import initialize_velopack
18
+ import traceback
19
+
20
+ try:
21
+ from synodic_client.config import set_dev_mode
22
+ from synodic_client.logging import configure_logging
23
+ from synodic_client.protocol import extract_uri_from_args
24
+ from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
25
+ from synodic_client.updater import initialize_velopack
26
+ except Exception:
27
+ # Last-resort crash log when imports fail before logging is configured.
28
+ import os
29
+
30
+ _fallback = os.path.join(os.environ.get('LOCALAPPDATA', '.'), 'Synodic', 'logs', 'bootstrap-crash.log')
31
+ os.makedirs(os.path.dirname(_fallback), exist_ok=True)
32
+ with open(_fallback, 'a', encoding='utf-8') as _f: # noqa: PTH123
33
+ _f.write(traceback.format_exc())
34
+ raise
23
35
 
24
36
  # Parse flags early so logging uses the right filename and level.
25
37
  _dev_mode = '--dev' in sys.argv[1:]
@@ -28,6 +40,10 @@ set_dev_mode(_dev_mode)
28
40
  _apply_subprocess_patch()
29
41
 
30
42
  configure_logging(debug=_debug)
43
+
44
+ _logger = logging.getLogger(__name__)
45
+ _logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv)
46
+
31
47
  initialize_velopack()
32
48
 
33
49
  if not _dev_mode:
@@ -19,7 +19,7 @@ import sys
19
19
 
20
20
  from synodic_client.protocol import register_protocol
21
21
  from synodic_client.resolution import resolve_config, seed_user_config_from_build
22
- from synodic_client.startup import register_startup, remove_startup
22
+ from synodic_client.startup import sync_startup
23
23
 
24
24
  logger = logging.getLogger(__name__)
25
25
 
@@ -59,10 +59,6 @@ def run_startup_preamble(exe_path: str | None = None) -> None:
59
59
  register_protocol(exe_path)
60
60
 
61
61
  config = resolve_config()
62
- if frozen:
63
- if config.auto_start:
64
- register_startup(exe_path)
65
- else:
66
- remove_startup()
62
+ sync_startup(exe_path, auto_start=config.auto_start)
67
63
 
68
64
  logger.info('Startup preamble complete (auto_start=%s, frozen=%s)', config.auto_start, frozen)
@@ -36,7 +36,7 @@ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
36
36
  from synodic_client.application.update_model import UpdateModel
37
37
  from synodic_client.logging import log_path, set_debug_level
38
38
  from synodic_client.schema import GITHUB_REPO_URL
39
- from synodic_client.startup import is_startup_registered, register_startup, remove_startup
39
+ from synodic_client.startup import is_startup_registered, sync_startup
40
40
 
41
41
  logger = logging.getLogger(__name__)
42
42
 
@@ -382,11 +382,7 @@ class SettingsWindow(QMainWindow):
382
382
 
383
383
  def _on_auto_start_changed(self, checked: bool) -> None:
384
384
  self._store.update(auto_start=checked)
385
- if getattr(sys, 'frozen', False):
386
- if checked:
387
- register_startup(sys.executable)
388
- else:
389
- remove_startup()
385
+ sync_startup(sys.executable, auto_start=checked)
390
386
 
391
387
  def _on_debug_logging_changed(self, checked: bool) -> None:
392
388
  set_debug_level(enabled=checked)
@@ -3,6 +3,7 @@
3
3
  import logging
4
4
  from typing import TYPE_CHECKING
5
5
 
6
+ from PySide6.QtCore import QTimer
6
7
  from PySide6.QtGui import QAction
7
8
  from PySide6.QtWidgets import (
8
9
  QApplication,
@@ -54,7 +55,12 @@ class TrayScreen:
54
55
  self.tray = QSystemTrayIcon()
55
56
  self.tray.setIcon(self.tray_icon)
56
57
  self.tray.activated.connect(self._on_tray_activated)
57
- self.tray.setVisible(True)
58
+
59
+ # At early Windows login the notification area may not be ready.
60
+ # Retry with back-off so the icon eventually appears.
61
+ self._tray_retry_count = 0
62
+ self._tray_retry_timer: QTimer | None = None
63
+ self._show_tray_icon()
58
64
 
59
65
  self._build_menu(app, window)
60
66
 
@@ -127,6 +133,37 @@ class TrayScreen:
127
133
 
128
134
  self.tray.setContextMenu(self.menu)
129
135
 
136
+ # Maximum number of tray-visibility retries at startup.
137
+ _TRAY_MAX_RETRIES = 5
138
+ # Delay between retries in milliseconds.
139
+ _TRAY_RETRY_DELAY_MS = 2000
140
+
141
+ def _show_tray_icon(self) -> None:
142
+ """Show the tray icon, retrying if the system tray is not ready."""
143
+ if QSystemTrayIcon.isSystemTrayAvailable():
144
+ self.tray.setVisible(True)
145
+ logger.debug('System tray icon shown')
146
+ return
147
+
148
+ if self._tray_retry_count < self._TRAY_MAX_RETRIES:
149
+ self._tray_retry_count += 1
150
+ logger.warning(
151
+ 'System tray not available, retrying (%d/%d)',
152
+ self._tray_retry_count,
153
+ self._TRAY_MAX_RETRIES,
154
+ )
155
+ self._tray_retry_timer = QTimer()
156
+ self._tray_retry_timer.setSingleShot(True)
157
+ self._tray_retry_timer.timeout.connect(self._show_tray_icon)
158
+ self._tray_retry_timer.start(self._TRAY_RETRY_DELAY_MS)
159
+ else:
160
+ # Exhausted retries — show anyway as a best-effort fallback.
161
+ logger.warning(
162
+ 'System tray still not available after %d retries, forcing visibility',
163
+ self._TRAY_MAX_RETRIES,
164
+ )
165
+ self.tray.setVisible(True)
166
+
130
167
  def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
131
168
  """Handle tray icon activation (e.g. double-click)."""
132
169
  if reason == QSystemTrayIcon.ActivationReason.DoubleClick:
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import asyncio
16
16
  import logging
17
+ import sys
17
18
  from collections.abc import Callable
18
19
  from datetime import UTC, datetime
19
20
  from typing import TYPE_CHECKING
@@ -34,6 +35,7 @@ from synodic_client.resolution import (
34
35
  resolve_update_config,
35
36
  )
36
37
  from synodic_client.schema import UpdateInfo
38
+ from synodic_client.startup import sync_startup
37
39
 
38
40
  if TYPE_CHECKING:
39
41
  from synodic_client.application.config_store import ConfigStore
@@ -413,6 +415,14 @@ class UpdateController:
413
415
  return
414
416
 
415
417
  try:
418
+ # Re-register the startup entry with the current exe path so
419
+ # the registry value stays valid even if Velopack relocates
420
+ # the binary during the update. The relaunched process will
421
+ # overwrite it again via run_startup_preamble, but this
422
+ # ensures the entry is never stale between the update and
423
+ # the next launch.
424
+ sync_startup(sys.executable, auto_start=self._store.config.auto_start)
425
+
416
426
  self._pending_version = None
417
427
  self._client.apply_update_on_exit(restart=True, silent=silent)
418
428
  logger.info('Update scheduled — restarting application')
@@ -96,6 +96,24 @@ if sys.platform == 'win32':
96
96
  except OSError:
97
97
  logger.exception('Failed to remove StartupApproved flag')
98
98
 
99
+ def get_registered_startup_path() -> str | None:
100
+ r"""Return the executable path stored in the ``Run`` registry key.
101
+
102
+ Returns:
103
+ The unquoted path string, or ``None`` when the value does
104
+ not exist or cannot be read.
105
+ """
106
+ try:
107
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
108
+ value, _ = winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
109
+ # The value is stored as '"<path>"'; strip the quotes.
110
+ return value.strip('"') if isinstance(value, str) else None
111
+ except FileNotFoundError:
112
+ return None
113
+ except OSError:
114
+ logger.exception('Failed to read auto-startup path from registry')
115
+ return None
116
+
99
117
  def is_startup_registered() -> bool:
100
118
  r"""Check whether auto-startup is both present **and** enabled.
101
119
 
@@ -147,6 +165,14 @@ else:
147
165
  """Remove auto-startup registration (no-op on non-Windows)."""
148
166
  logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform)
149
167
 
168
+ def get_registered_startup_path() -> str | None:
169
+ """Return the registered startup path (always ``None`` on non-Windows).
170
+
171
+ Returns:
172
+ ``None``.
173
+ """
174
+ return None
175
+
150
176
  def is_startup_registered() -> bool:
151
177
  """Check auto-startup registration (always ``False`` on non-Windows).
152
178
 
@@ -154,3 +180,34 @@ else:
154
180
  ``False``.
155
181
  """
156
182
  return False
183
+
184
+
185
+ def sync_startup(exe_path: str, *, auto_start: bool) -> None:
186
+ """Synchronise the auto-startup registry state with the given preference.
187
+
188
+ Registers or removes the startup entry and logs a warning when
189
+ the previously registered path differs from *exe_path* (stale
190
+ path after a Velopack update, for example).
191
+
192
+ This is a no-op when ``sys.frozen`` is falsy (non-installed
193
+ builds never touch the registry).
194
+
195
+ Args:
196
+ exe_path: Absolute path to the application executable.
197
+ auto_start: Whether auto-startup should be enabled.
198
+ """
199
+ if not getattr(sys, 'frozen', False):
200
+ return
201
+
202
+ registered_path = get_registered_startup_path()
203
+ if registered_path and registered_path != exe_path:
204
+ logger.warning(
205
+ 'Startup registry path mismatch: registered=%s, current=%s',
206
+ registered_path,
207
+ exe_path,
208
+ )
209
+
210
+ if auto_start:
211
+ register_startup(exe_path)
212
+ else:
213
+ remove_startup()
@@ -238,24 +238,23 @@ class TestSettingsCallbacks:
238
238
 
239
239
  @staticmethod
240
240
  def test_auto_start_registers_startup_when_frozen() -> None:
241
- """Enabling auto-start calls register_startup in frozen builds."""
241
+ """Enabling auto-start calls sync_startup."""
242
242
  config = _make_config()
243
243
  window = _make_window(config)
244
244
 
245
245
  new_config = _make_config(auto_start=True)
246
246
  with (
247
247
  patch.object(window._store, 'update', return_value=new_config),
248
- patch('synodic_client.application.screen.settings.register_startup') as mock_register,
248
+ patch('synodic_client.application.screen.settings.sync_startup') as mock_sync,
249
249
  patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False),
250
- patch('synodic_client.application.screen.settings.getattr', return_value=True),
251
250
  ):
252
251
  window._auto_start_check.setChecked(True)
253
252
 
254
- mock_register.assert_called_once()
253
+ mock_sync.assert_called_once()
255
254
 
256
255
  @staticmethod
257
256
  def test_auto_start_removes_startup_when_frozen() -> None:
258
- """Disabling auto-start calls remove_startup in frozen builds."""
257
+ """Disabling auto-start calls sync_startup with auto_start=False."""
259
258
  config = _make_config(auto_start=True)
260
259
  window = _make_window(config)
261
260
  # Manually set initial state without triggering signals
@@ -266,31 +265,28 @@ class TestSettingsCallbacks:
266
265
  new_config = _make_config(auto_start=False)
267
266
  with (
268
267
  patch.object(window._store, 'update', return_value=new_config),
269
- patch('synodic_client.application.screen.settings.remove_startup') as mock_remove,
270
- patch('synodic_client.application.screen.settings.getattr', return_value=True),
268
+ patch('synodic_client.application.screen.settings.sync_startup') as mock_sync,
271
269
  ):
272
270
  window._auto_start_check.setChecked(False)
273
271
 
274
- mock_remove.assert_called_once()
272
+ mock_sync.assert_called_once()
275
273
 
276
274
  @staticmethod
277
275
  def test_auto_start_skips_registry_when_not_frozen() -> None:
278
- """Auto-start toggle persists config but skips registry in non-frozen builds."""
276
+ """Auto-start toggle persists config and delegates to sync_startup."""
279
277
  config = _make_config()
280
278
  window = _make_window(config)
281
279
 
282
280
  new_config = _make_config(auto_start=True)
283
281
  with (
284
282
  patch.object(window._store, 'update', return_value=new_config),
285
- patch('synodic_client.application.screen.settings.register_startup') as mock_register,
286
- patch('synodic_client.application.screen.settings.remove_startup') as mock_remove,
283
+ patch('synodic_client.application.screen.settings.sync_startup') as mock_sync,
287
284
  patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False),
288
- patch('synodic_client.application.screen.settings.getattr', return_value=False),
289
285
  ):
290
286
  window._auto_start_check.setChecked(True)
291
287
 
292
- mock_register.assert_not_called()
293
- mock_remove.assert_not_called()
288
+ # sync_startup is always called — it handles the frozen guard internally
289
+ mock_sync.assert_called_once()
294
290
 
295
291
 
296
292
  # ---------------------------------------------------------------------------
@@ -23,26 +23,6 @@ from synodic_client.schema import (
23
23
  UpdateInfo,
24
24
  )
25
25
 
26
-
27
- # ---------------------------------------------------------------------------
28
- # Helpers
29
- # ---------------------------------------------------------------------------
30
-
31
-
32
- class ModelSpy:
33
- """Records signal emissions from an :class:`UpdateModel`."""
34
-
35
- def __init__(self, model: UpdateModel) -> None:
36
- self.status: list[tuple[str, str]] = []
37
- self.check_button_enabled: list[bool] = []
38
- self.restart_visible: list[bool] = []
39
- self.last_checked: list[str] = []
40
-
41
- model.status_text_changed.connect(lambda t, s: self.status.append((t, s)))
42
- model.check_button_enabled_changed.connect(self.check_button_enabled.append)
43
- model.restart_visible_changed.connect(self.restart_visible.append)
44
- model.last_checked_changed.connect(self.last_checked.append)
45
-
46
26
  # ---------------------------------------------------------------------------
47
27
  # Helpers
48
28
  # ---------------------------------------------------------------------------
@@ -382,6 +362,22 @@ class TestApplyUpdate:
382
362
  client.apply_update_on_exit.assert_not_called()
383
363
  app.quit.assert_not_called()
384
364
 
365
+ @staticmethod
366
+ def test_apply_update_refreshes_startup_registry_when_frozen() -> None:
367
+ """_apply_update should call sync_startup before quitting."""
368
+ ctrl, app, client, banner, model = _make_controller()
369
+
370
+ with (
371
+ patch('synodic_client.application.update_controller.sync_startup') as mock_sync,
372
+ patch('synodic_client.application.update_controller.sys') as mock_sys,
373
+ ):
374
+ mock_sys.executable = r'C:\app\synodic.exe'
375
+ ctrl._apply_update()
376
+
377
+ mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=True)
378
+ client.apply_update_on_exit.assert_called_once()
379
+ app.quit.assert_called_once()
380
+
385
381
 
386
382
  # ---------------------------------------------------------------------------
387
383
  # Settings changed → immediate check
@@ -26,8 +26,7 @@ class TestRunStartupPreamble:
26
26
  patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed,
27
27
  patch(f'{_MODULE}.register_protocol') as mock_proto,
28
28
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
29
- patch(f'{_MODULE}.register_startup'),
30
- patch(f'{_MODULE}.remove_startup'),
29
+ patch(f'{_MODULE}.sync_startup'),
31
30
  patch(f'{_MODULE}.getattr', return_value=True),
32
31
  ):
33
32
  mock_resolve.return_value = MagicMock(auto_start=True)
@@ -38,38 +37,34 @@ class TestRunStartupPreamble:
38
37
  mock_resolve.assert_called_once()
39
38
 
40
39
  @staticmethod
41
- def test_registers_startup_when_auto_start_true() -> None:
42
- """register_startup is called when auto_start is True and frozen."""
40
+ def test_delegates_to_sync_startup_with_auto_start() -> None:
41
+ """sync_startup is called with the resolved auto_start preference."""
43
42
  with (
44
43
  patch(f'{_MODULE}.seed_user_config_from_build'),
45
44
  patch(f'{_MODULE}.register_protocol'),
46
45
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
47
- patch(f'{_MODULE}.register_startup') as mock_register,
48
- patch(f'{_MODULE}.remove_startup') as mock_remove,
46
+ patch(f'{_MODULE}.sync_startup') as mock_sync,
49
47
  patch(f'{_MODULE}.getattr', return_value=True),
50
48
  ):
51
49
  mock_resolve.return_value = MagicMock(auto_start=True)
52
50
  run_startup_preamble(r'C:\app\synodic.exe')
53
51
 
54
- mock_register.assert_called_once_with(r'C:\app\synodic.exe')
55
- mock_remove.assert_not_called()
52
+ mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=True)
56
53
 
57
54
  @staticmethod
58
- def test_removes_startup_when_auto_start_false() -> None:
59
- """remove_startup is called when auto_start is False and frozen."""
55
+ def test_delegates_to_sync_startup_when_auto_start_false() -> None:
56
+ """sync_startup receives auto_start=False when config says so."""
60
57
  with (
61
58
  patch(f'{_MODULE}.seed_user_config_from_build'),
62
59
  patch(f'{_MODULE}.register_protocol'),
63
60
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
64
- patch(f'{_MODULE}.register_startup') as mock_register,
65
- patch(f'{_MODULE}.remove_startup') as mock_remove,
61
+ patch(f'{_MODULE}.sync_startup') as mock_sync,
66
62
  patch(f'{_MODULE}.getattr', return_value=True),
67
63
  ):
68
64
  mock_resolve.return_value = MagicMock(auto_start=False)
69
65
  run_startup_preamble(r'C:\app\synodic.exe')
70
66
 
71
- mock_remove.assert_called_once()
72
- mock_register.assert_not_called()
67
+ mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=False)
73
68
 
74
69
  @staticmethod
75
70
  def test_defaults_exe_path_to_sys_executable() -> None:
@@ -78,8 +73,7 @@ class TestRunStartupPreamble:
78
73
  patch(f'{_MODULE}.seed_user_config_from_build'),
79
74
  patch(f'{_MODULE}.register_protocol') as mock_proto,
80
75
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
81
- patch(f'{_MODULE}.register_startup') as mock_register,
82
- patch(f'{_MODULE}.remove_startup'),
76
+ patch(f'{_MODULE}.sync_startup') as mock_sync,
83
77
  patch(f'{_MODULE}.sys') as mock_sys,
84
78
  patch(f'{_MODULE}.getattr', return_value=True),
85
79
  ):
@@ -88,25 +82,24 @@ class TestRunStartupPreamble:
88
82
  run_startup_preamble()
89
83
 
90
84
  mock_proto.assert_called_once_with(r'C:\Python\python.exe')
91
- mock_register.assert_called_once_with(r'C:\Python\python.exe')
85
+ mock_sync.assert_called_once_with(r'C:\Python\python.exe', auto_start=True)
92
86
 
93
87
  @staticmethod
94
- def test_skips_registry_when_not_frozen() -> None:
95
- """Protocol and startup registration are skipped in non-frozen builds."""
88
+ def test_skips_protocol_when_not_frozen() -> None:
89
+ """Protocol registration is skipped in non-frozen builds."""
96
90
  with (
97
91
  patch(f'{_MODULE}.seed_user_config_from_build'),
98
92
  patch(f'{_MODULE}.register_protocol') as mock_proto,
99
93
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
100
- patch(f'{_MODULE}.register_startup') as mock_register,
101
- patch(f'{_MODULE}.remove_startup') as mock_remove,
94
+ patch(f'{_MODULE}.sync_startup') as mock_sync,
102
95
  patch(f'{_MODULE}.getattr', return_value=False),
103
96
  ):
104
97
  mock_resolve.return_value = MagicMock(auto_start=True)
105
98
  run_startup_preamble(r'C:\Python\python.exe')
106
99
 
107
100
  mock_proto.assert_not_called()
108
- mock_register.assert_not_called()
109
- mock_remove.assert_not_called()
101
+ # sync_startup is still called — it handles the frozen guard internally
102
+ mock_sync.assert_called_once()
110
103
 
111
104
  @staticmethod
112
105
  def test_idempotent_on_second_call() -> None:
@@ -115,8 +108,7 @@ class TestRunStartupPreamble:
115
108
  patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed,
116
109
  patch(f'{_MODULE}.register_protocol'),
117
110
  patch(f'{_MODULE}.resolve_config') as mock_resolve,
118
- patch(f'{_MODULE}.register_startup'),
119
- patch(f'{_MODULE}.remove_startup'),
111
+ patch(f'{_MODULE}.sync_startup'),
120
112
  ):
121
113
  mock_resolve.return_value = MagicMock(auto_start=True)
122
114
  run_startup_preamble(r'C:\app\synodic.exe')
@@ -3,14 +3,18 @@
3
3
  import winreg
4
4
  from unittest.mock import MagicMock, patch
5
5
 
6
+ import pytest
7
+
6
8
  from synodic_client.startup import (
7
9
  APPROVED_ENABLED,
8
10
  RUN_KEY_PATH,
9
11
  STARTUP_APPROVED_KEY_PATH,
10
12
  STARTUP_VALUE_NAME,
13
+ get_registered_startup_path,
11
14
  is_startup_registered,
12
15
  register_startup,
13
16
  remove_startup,
17
+ sync_startup,
14
18
  )
15
19
 
16
20
 
@@ -238,3 +242,124 @@ class TestIsStartupRegistered:
238
242
  patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
239
243
  ):
240
244
  assert is_startup_registered() is False
245
+
246
+
247
+ class TestGetRegisteredStartupPath:
248
+ """Tests for get_registered_startup_path."""
249
+
250
+ @staticmethod
251
+ def test_returns_unquoted_path() -> None:
252
+ """Verify the returned path has surrounding quotes stripped."""
253
+ mock_key = MagicMock()
254
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
255
+ mock_key.__exit__ = MagicMock(return_value=False)
256
+
257
+ with (
258
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
259
+ patch.object(
260
+ winreg,
261
+ 'QueryValueEx',
262
+ return_value=(r'"C:\Program Files\Synodic\synodic.exe"', winreg.REG_SZ),
263
+ ),
264
+ ):
265
+ assert get_registered_startup_path() == r'C:\Program Files\Synodic\synodic.exe'
266
+
267
+ @staticmethod
268
+ def test_returns_none_when_missing() -> None:
269
+ """Verify None when the registry value does not exist."""
270
+ mock_key = MagicMock()
271
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
272
+ mock_key.__exit__ = MagicMock(return_value=False)
273
+
274
+ with (
275
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
276
+ patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
277
+ ):
278
+ assert get_registered_startup_path() is None
279
+
280
+ @staticmethod
281
+ def test_returns_none_on_os_error() -> None:
282
+ """Verify None when an OSError prevents reading the registry."""
283
+ mock_key = MagicMock()
284
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
285
+ mock_key.__exit__ = MagicMock(return_value=False)
286
+
287
+ with (
288
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
289
+ patch.object(winreg, 'QueryValueEx', side_effect=OSError('access denied')),
290
+ ):
291
+ assert get_registered_startup_path() is None
292
+
293
+
294
+ _SYNC_MODULE = 'synodic_client.startup'
295
+
296
+
297
+ class TestSyncStartup:
298
+ """Tests for sync_startup."""
299
+
300
+ @staticmethod
301
+ def test_registers_when_auto_start_true() -> None:
302
+ """sync_startup calls register_startup when auto_start is True."""
303
+ with (
304
+ patch(f'{_SYNC_MODULE}.getattr', return_value=True),
305
+ patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=None),
306
+ patch(f'{_SYNC_MODULE}.register_startup') as mock_reg,
307
+ patch(f'{_SYNC_MODULE}.remove_startup') as mock_rem,
308
+ ):
309
+ sync_startup(r'C:\app\synodic.exe', auto_start=True)
310
+
311
+ mock_reg.assert_called_once_with(r'C:\app\synodic.exe')
312
+ mock_rem.assert_not_called()
313
+
314
+ @staticmethod
315
+ def test_removes_when_auto_start_false() -> None:
316
+ """sync_startup calls remove_startup when auto_start is False."""
317
+ with (
318
+ patch(f'{_SYNC_MODULE}.getattr', return_value=True),
319
+ patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=None),
320
+ patch(f'{_SYNC_MODULE}.register_startup') as mock_reg,
321
+ patch(f'{_SYNC_MODULE}.remove_startup') as mock_rem,
322
+ ):
323
+ sync_startup(r'C:\app\synodic.exe', auto_start=False)
324
+
325
+ mock_rem.assert_called_once()
326
+ mock_reg.assert_not_called()
327
+
328
+ @staticmethod
329
+ def test_noop_when_not_frozen() -> None:
330
+ """sync_startup is a no-op when sys.frozen is falsy."""
331
+ with (
332
+ patch(f'{_SYNC_MODULE}.getattr', return_value=False),
333
+ patch(f'{_SYNC_MODULE}.register_startup') as mock_reg,
334
+ patch(f'{_SYNC_MODULE}.remove_startup') as mock_rem,
335
+ ):
336
+ sync_startup(r'C:\app\synodic.exe', auto_start=True)
337
+
338
+ mock_reg.assert_not_called()
339
+ mock_rem.assert_not_called()
340
+
341
+ @staticmethod
342
+ def test_logs_warning_on_path_mismatch(caplog: pytest.LogCaptureFixture) -> None:
343
+ """A warning is logged when the registered path differs from exe_path."""
344
+ with (
345
+ patch(f'{_SYNC_MODULE}.getattr', return_value=True),
346
+ patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=r'C:\old\synodic.exe'),
347
+ patch(f'{_SYNC_MODULE}.register_startup'),
348
+ patch(f'{_SYNC_MODULE}.remove_startup'),
349
+ ):
350
+ sync_startup(r'C:\new\synodic.exe', auto_start=True)
351
+
352
+ assert 'mismatch' in caplog.text.lower()
353
+
354
+ @staticmethod
355
+ def test_no_warning_when_paths_match(caplog: pytest.LogCaptureFixture) -> None:
356
+ """No warning when the registered path matches exe_path."""
357
+ with (
358
+ patch(f'{_SYNC_MODULE}.getattr', return_value=True),
359
+ patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=r'C:\app\synodic.exe'),
360
+ patch(f'{_SYNC_MODULE}.register_startup'),
361
+ patch(f'{_SYNC_MODULE}.remove_startup'),
362
+ ):
363
+ sync_startup(r'C:\app\synodic.exe', auto_start=True)
364
+
365
+ assert 'mismatch' not in caplog.text.lower()