synodic-client 0.0.1.dev42__tar.gz → 0.0.1.dev45__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 (73) hide show
  1. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/pyproject.toml +2 -2
  3. synodic_client-0.0.1.dev45/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/bootstrap.py +3 -13
  5. synodic_client-0.0.1.dev45/synodic_client/application/init.py +61 -0
  6. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/qt.py +4 -16
  7. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/settings.py +6 -0
  8. synodic_client-0.0.1.dev45/synodic_client/startup.py +156 -0
  9. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/updater.py +11 -3
  10. synodic_client-0.0.1.dev45/tests/unit/test_init.py +103 -0
  11. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_updater.py +16 -0
  12. synodic_client-0.0.1.dev45/tests/unit/windows/test_startup.py +240 -0
  13. synodic_client-0.0.1.dev42/synodic_client/_version.py +0 -1
  14. synodic_client-0.0.1.dev42/synodic_client/startup.py +0 -91
  15. synodic_client-0.0.1.dev42/tests/unit/windows/test_startup.py +0 -121
  16. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/LICENSE.md +0 -0
  17. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/README.md +0 -0
  18. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/__init__.py +0 -0
  19. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/__main__.py +0 -0
  20. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/data.py +0 -0
  22. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/icon.py +0 -0
  23. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/instance.py +0 -0
  24. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/__init__.py +0 -0
  25. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/action_card.py +0 -0
  26. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/card.py +0 -0
  27. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/install.py +0 -0
  28. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/log_panel.py +0 -0
  29. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/screen.py +0 -0
  30. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/sidebar.py +0 -0
  31. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/spinner.py +0 -0
  32. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/tray.py +0 -0
  33. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/screen/update_banner.py +0 -0
  34. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/theme.py +0 -0
  35. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/update_controller.py +0 -0
  36. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/uri.py +0 -0
  37. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/application/workers.py +0 -0
  38. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/cli.py +0 -0
  39. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/client.py +0 -0
  40. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/config.py +0 -0
  41. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/logging.py +0 -0
  42. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/protocol.py +0 -0
  43. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/py.typed +0 -0
  44. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/synodic_client/resolution.py +0 -0
  45. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/__init__.py +0 -0
  46. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/conftest.py +0 -0
  47. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/__init__.py +0 -0
  48. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/__init__.py +0 -0
  49. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/conftest.py +0 -0
  50. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_action_card.py +0 -0
  51. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_gather_packages.py +0 -0
  52. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_install_preview.py +0 -0
  53. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_log_panel.py +0 -0
  54. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_logging.py +0 -0
  55. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_preview_model.py +0 -0
  56. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_settings.py +0 -0
  57. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_sidebar.py +0 -0
  58. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_tray_window_show.py +0 -0
  59. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_update_banner.py +0 -0
  60. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_update_controller.py +0 -0
  61. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/qt/test_update_feedback.py +0 -0
  62. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_cli.py +0 -0
  63. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_client_updater.py +0 -0
  64. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_client_version.py +0 -0
  65. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_config.py +0 -0
  66. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_examples.py +0 -0
  67. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_install.py +0 -0
  68. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_resolution.py +0 -0
  69. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_uri.py +0 -0
  70. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/test_workers.py +0 -0
  71. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/windows/__init__.py +0 -0
  72. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/tests/unit/windows/conftest.py +0 -0
  73. {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev45}/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.dev42
3
+ Version: 0.0.1.dev45
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.dev71
11
+ Requires-Dist: porringer>=0.2.1.dev72
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.dev71",
13
+ "porringer>=0.2.1.dev72",
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.dev42"
18
+ version = "0.0.1.dev45"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev45'
@@ -9,7 +9,7 @@ Import order matters:
9
9
  1. stdlib + config (pure-Python, fast)
10
10
  2. configure_logging() — now Qt-free
11
11
  3. initialize_velopack() — hooks run with logging active
12
- 4. register_protocol() — stdlib only
12
+ 4. run_startup_preamble() — protocol, config seed, auto-startup
13
13
  5. import qt.application — PySide6 / porringer loaded here
14
14
  """
15
15
 
@@ -17,9 +17,6 @@ import sys
17
17
 
18
18
  from synodic_client.config import set_dev_mode
19
19
  from synodic_client.logging import configure_logging
20
- from synodic_client.protocol import register_protocol
21
- from synodic_client.resolution import resolve_config, seed_user_config_from_build
22
- from synodic_client.startup import register_startup, remove_startup
23
20
  from synodic_client.updater import initialize_velopack
24
21
 
25
22
  _PROTOCOL_SCHEME = 'synodic'
@@ -32,16 +29,9 @@ configure_logging()
32
29
  initialize_velopack()
33
30
 
34
31
  if not _dev_mode:
35
- # Seed user config from the build config (one-time propagation).
36
- seed_user_config_from_build()
32
+ from synodic_client.application.init import run_startup_preamble
37
33
 
38
- register_protocol(sys.executable)
39
-
40
- _config = resolve_config()
41
- if _config.auto_start:
42
- register_startup(sys.executable)
43
- else:
44
- remove_startup()
34
+ run_startup_preamble(sys.executable)
45
35
 
46
36
  # Heavy imports happen here — PySide6, porringer, etc.
47
37
  from synodic_client.application.qt import application
@@ -0,0 +1,61 @@
1
+ """Shared startup preamble for frozen and CLI entry points.
2
+
3
+ Encapsulates the one-time initialisation that both
4
+ :mod:`synodic_client.application.bootstrap` (PyInstaller) and
5
+ :mod:`synodic_client.application.qt` (CLI / dev-script) need to
6
+ perform before the GUI event loop starts:
7
+
8
+ 1. Seed user config from the build config (one-time propagation).
9
+ 2. Register the ``synodic://`` URI protocol handler.
10
+ 3. Synchronise the Windows auto-startup registry entry with the
11
+ persisted ``auto_start`` preference.
12
+
13
+ Heavy dependencies (PySide6, porringer) are **not** imported here so
14
+ that the bootstrap path can call this before loading them.
15
+ """
16
+
17
+ import logging
18
+ import sys
19
+
20
+ from synodic_client.protocol import register_protocol
21
+ from synodic_client.resolution import resolve_config, seed_user_config_from_build
22
+ from synodic_client.startup import register_startup, remove_startup
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _preamble_done = False
27
+
28
+
29
+ def run_startup_preamble(exe_path: str | None = None) -> None:
30
+ """Run the shared startup preamble for non-dev-mode launches.
31
+
32
+ Both the frozen entry point
33
+ (:mod:`~synodic_client.application.bootstrap`) and the CLI entry
34
+ point (:func:`~synodic_client.application.qt.application`) call
35
+ this unconditionally. An internal guard ensures the work only
36
+ executes once per process.
37
+
38
+ Args:
39
+ exe_path: Absolute path to the application executable. Defaults
40
+ to ``sys.executable`` when not supplied.
41
+ """
42
+ global _preamble_done # noqa: PLW0603
43
+ if _preamble_done:
44
+ return
45
+ _preamble_done = True
46
+
47
+ if exe_path is None:
48
+ exe_path = sys.executable
49
+
50
+ # Seed user config from the build config (one-time propagation).
51
+ seed_user_config_from_build()
52
+
53
+ register_protocol(exe_path)
54
+
55
+ config = resolve_config()
56
+ if config.auto_start:
57
+ register_startup(exe_path)
58
+ else:
59
+ remove_startup()
60
+
61
+ logger.info('Startup preamble complete (auto_start=%s)', config.auto_start)
@@ -15,6 +15,7 @@ from PySide6.QtCore import Qt, QTimer
15
15
  from PySide6.QtWidgets import QApplication
16
16
 
17
17
  from synodic_client.application.icon import app_icon
18
+ from synodic_client.application.init import run_startup_preamble
18
19
  from synodic_client.application.instance import SingleInstance
19
20
  from synodic_client.application.screen.install import InstallPreviewWindow
20
21
  from synodic_client.application.screen.screen import Screen
@@ -23,15 +24,12 @@ from synodic_client.application.uri import parse_uri
23
24
  from synodic_client.client import Client
24
25
  from synodic_client.config import set_dev_mode
25
26
  from synodic_client.logging import configure_logging
26
- from synodic_client.protocol import register_protocol
27
27
  from synodic_client.resolution import (
28
28
  ResolvedConfig,
29
29
  resolve_config,
30
30
  resolve_update_config,
31
31
  resolve_version,
32
- seed_user_config_from_build,
33
32
  )
34
- from synodic_client.startup import register_startup, remove_startup
35
33
  from synodic_client.updater import initialize_velopack
36
34
 
37
35
 
@@ -139,20 +137,10 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
139
137
  _install_exception_hook(logger)
140
138
 
141
139
  if not dev_mode:
142
- # Initialize Velopack early, before any UI.
143
- # Console window suppression for subprocesses is handled by the
144
- # PyInstaller runtime hook (rthook_no_console.py).
140
+ # All three functions are idempotent — safe to call even when
141
+ # bootstrap.py has already executed them before heavy imports.
145
142
  initialize_velopack()
146
- register_protocol(sys.executable)
147
-
148
- # Seed user config from build config (one-time propagation).
149
- seed_user_config_from_build()
150
-
151
- startup_config = resolve_config()
152
- if startup_config.auto_start:
153
- register_startup(sys.executable)
154
- else:
155
- remove_startup()
143
+ run_startup_preamble(sys.executable)
156
144
 
157
145
  if uri:
158
146
  logger.info('Received URI: %s', uri)
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
27
27
  QWidget,
28
28
  )
29
29
 
30
+ from synodic_client._version import __version__
30
31
  from synodic_client.application.icon import app_icon
31
32
  from synodic_client.application.screen.card import CardFrame
32
33
  from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
@@ -92,6 +93,11 @@ class SettingsWindow(QMainWindow):
92
93
  layout.addWidget(self._build_advanced_section())
93
94
  layout.addStretch()
94
95
 
96
+ version_label = QLabel(f'Version {__version__}')
97
+ version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
98
+ version_label.setStyleSheet('color: rgba(255, 255, 255, 0.4); font-size: 11px;')
99
+ layout.addWidget(version_label)
100
+
95
101
  scroll.setWidget(container)
96
102
  self.setCentralWidget(scroll)
97
103
 
@@ -0,0 +1,156 @@
1
+ r"""Windows auto-startup registration via the registry.
2
+
3
+ Manages a value under ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
4
+ so the application launches automatically when the user logs in.
5
+
6
+ Windows also maintains a parallel
7
+ ``HKCU\...\Explorer\StartupApproved\Run`` key where each entry is a
8
+ 12-byte ``REG_BINARY`` value. Byte 0 controls the enabled state:
9
+
10
+ * ``0x02`` — **enabled** (Windows will honour the ``Run`` entry).
11
+ * ``0x03`` — **disabled** (entry hidden from startup by Task Manager /
12
+ Settings → Startup Apps).
13
+
14
+ When registering or removing auto-startup we synchronise *both* keys
15
+ so that a user toggling the setting in-app overrides any prior
16
+ Task-Manager disable.
17
+
18
+ Other platforms are stubbed with no-op implementations, matching the
19
+ approach in :mod:`synodic_client.protocol`.
20
+ """
21
+
22
+ import logging
23
+ import sys
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ STARTUP_VALUE_NAME = 'SynodicClient'
28
+ """Registry value name used in the ``Run`` key."""
29
+
30
+ RUN_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Run'
31
+
32
+ STARTUP_APPROVED_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'
33
+ """Registry key where Windows stores per-entry enabled/disabled flags."""
34
+
35
+ # 12-byte REG_BINARY payloads for the StartupApproved value.
36
+ APPROVED_ENABLED: bytes = b'\x02' + b'\x00' * 11
37
+ """Enabled payload for the ``StartupApproved\\Run`` registry value."""
38
+
39
+ _APPROVED_DISABLED_BYTE: int = 0x03
40
+
41
+
42
+ if sys.platform == 'win32':
43
+ import winreg
44
+
45
+ def register_startup(exe_path: str) -> None:
46
+ r"""Register the application to start automatically on login.
47
+
48
+ Writes a value to ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
49
+ pointing to *exe_path* **and** writes an *enabled* flag to the
50
+ corresponding ``StartupApproved\Run`` key so that a previous
51
+ Task-Manager disable is overridden.
52
+
53
+ Calling this repeatedly is safe and will update the path (useful
54
+ after Velopack relocates the executable).
55
+
56
+ Args:
57
+ exe_path: Absolute path to the application executable.
58
+ """
59
+ try:
60
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
61
+ winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_SZ, f'"{exe_path}"')
62
+ logger.info('Registered auto-startup -> %s', exe_path)
63
+ except OSError:
64
+ logger.exception('Failed to register auto-startup')
65
+
66
+ # Ensure Windows considers the entry enabled even if the user
67
+ # previously disabled it via Task Manager.
68
+ try:
69
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH) as key:
70
+ winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_BINARY, APPROVED_ENABLED)
71
+ logger.debug('Wrote StartupApproved enabled flag')
72
+ except OSError:
73
+ logger.exception('Failed to write StartupApproved enabled flag')
74
+
75
+ def remove_startup() -> None:
76
+ """Remove the auto-startup registration.
77
+
78
+ Removes both the ``Run`` value and any ``StartupApproved`` flag.
79
+ Silently succeeds if the values do not exist.
80
+ """
81
+ try:
82
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
83
+ winreg.DeleteValue(key, STARTUP_VALUE_NAME)
84
+ logger.info('Removed auto-startup registration')
85
+ except FileNotFoundError:
86
+ logger.debug('Auto-startup registration not found, nothing to remove')
87
+ except OSError:
88
+ logger.exception('Failed to remove auto-startup registration')
89
+
90
+ try:
91
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
92
+ winreg.DeleteValue(key, STARTUP_VALUE_NAME)
93
+ logger.debug('Removed StartupApproved flag')
94
+ except FileNotFoundError:
95
+ logger.debug('StartupApproved flag not found, nothing to remove')
96
+ except OSError:
97
+ logger.exception('Failed to remove StartupApproved flag')
98
+
99
+ def is_startup_registered() -> bool:
100
+ r"""Check whether auto-startup is both present **and** enabled.
101
+
102
+ Returns ``True`` only when the ``Run`` value exists and Windows
103
+ has not disabled it via ``StartupApproved\Run``.
104
+
105
+ Returns:
106
+ ``True`` if the application will auto-start on login.
107
+ """
108
+ # 1. Check the Run key exists at all.
109
+ try:
110
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
111
+ winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
112
+ except FileNotFoundError:
113
+ return False
114
+ except OSError:
115
+ logger.exception('Failed to query auto-startup registration')
116
+ return False
117
+
118
+ # 2. Check the StartupApproved override. If the key/value is
119
+ # absent the entry is considered enabled (Windows only writes
120
+ # this key when the user explicitly disables/enables via Task
121
+ # Manager or Settings).
122
+ try:
123
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
124
+ data, _ = winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
125
+ if isinstance(data, bytes) and len(data) >= 1 and data[0] == _APPROVED_DISABLED_BYTE:
126
+ logger.debug('Auto-startup is disabled via StartupApproved')
127
+ return False
128
+ except FileNotFoundError:
129
+ # No approval override → treat as enabled.
130
+ pass
131
+ except OSError:
132
+ logger.exception('Failed to query StartupApproved flag')
133
+
134
+ return True
135
+
136
+ else:
137
+
138
+ def register_startup(exe_path: str) -> None:
139
+ """Register auto-startup (no-op on non-Windows).
140
+
141
+ Args:
142
+ exe_path: Absolute path to the application executable.
143
+ """
144
+ logger.warning('Auto-startup registration is only supported on Windows (current: %s)', sys.platform)
145
+
146
+ def remove_startup() -> None:
147
+ """Remove auto-startup registration (no-op on non-Windows)."""
148
+ logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform)
149
+
150
+ def is_startup_registered() -> bool:
151
+ """Check auto-startup registration (always ``False`` on non-Windows).
152
+
153
+ Returns:
154
+ ``False``.
155
+ """
156
+ return False
@@ -113,10 +113,10 @@ class UpdateInfo:
113
113
 
114
114
 
115
115
  # Default interval for automatic update checks (minutes)
116
- DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES = 30
116
+ DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES = 5
117
117
 
118
118
  # Default interval for tool update checks (minutes)
119
- DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES = 20
119
+ DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES = 5
120
120
 
121
121
 
122
122
  @dataclass
@@ -433,6 +433,9 @@ def _on_before_uninstall(version: str) -> None:
433
433
  logger.warning('Auto-startup removal failed during uninstall hook', exc_info=True)
434
434
 
435
435
 
436
+ _velopack_initialized = False
437
+
438
+
436
439
  def initialize_velopack() -> None:
437
440
  """Initialize Velopack at application startup.
438
441
 
@@ -440,13 +443,18 @@ def initialize_velopack() -> None:
440
443
  before any UI is shown. Velopack may need to perform cleanup or apply
441
444
  pending updates.
442
445
 
443
- Protocol registration happens on every app launch (see ``qt.application``).
446
+ Safe to call more than once subsequent calls are no-ops.
444
447
 
445
448
  .. note::
446
449
 
447
450
  The SDK's callback hooks only accept ``PyCFunction`` — add an
448
451
  uninstall hook here when that is fixed upstream.
449
452
  """
453
+ global _velopack_initialized # noqa: PLW0603
454
+ if _velopack_initialized:
455
+ return
456
+ _velopack_initialized = True
457
+
450
458
  logger.info('Initializing Velopack (exe=%s)', sys.executable)
451
459
  try:
452
460
  app = velopack.App()
@@ -0,0 +1,103 @@
1
+ """Tests for the shared startup preamble."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ import synodic_client.application.init as init_mod
8
+ from synodic_client.application.init import run_startup_preamble
9
+
10
+ _MODULE = 'synodic_client.application.init'
11
+
12
+
13
+ @pytest.fixture(autouse=True)
14
+ def _reset_preamble_guard() -> None:
15
+ """Reset the idempotency guard before each test."""
16
+ init_mod._preamble_done = False
17
+
18
+
19
+ class TestRunStartupPreamble:
20
+ """Verify that run_startup_preamble orchestrates the correct calls."""
21
+
22
+ @staticmethod
23
+ def test_calls_seed_and_register_protocol() -> None:
24
+ """Seed, protocol registration, and config resolution are invoked."""
25
+ with (
26
+ patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed,
27
+ patch(f'{_MODULE}.register_protocol') as mock_proto,
28
+ patch(f'{_MODULE}.resolve_config') as mock_resolve,
29
+ patch(f'{_MODULE}.register_startup'),
30
+ patch(f'{_MODULE}.remove_startup'),
31
+ ):
32
+ mock_resolve.return_value = MagicMock(auto_start=True)
33
+ run_startup_preamble(r'C:\app\synodic.exe')
34
+
35
+ mock_seed.assert_called_once()
36
+ mock_proto.assert_called_once_with(r'C:\app\synodic.exe')
37
+ mock_resolve.assert_called_once()
38
+
39
+ @staticmethod
40
+ def test_registers_startup_when_auto_start_true() -> None:
41
+ """register_startup is called when auto_start is True."""
42
+ with (
43
+ patch(f'{_MODULE}.seed_user_config_from_build'),
44
+ patch(f'{_MODULE}.register_protocol'),
45
+ patch(f'{_MODULE}.resolve_config') as mock_resolve,
46
+ patch(f'{_MODULE}.register_startup') as mock_register,
47
+ patch(f'{_MODULE}.remove_startup') as mock_remove,
48
+ ):
49
+ mock_resolve.return_value = MagicMock(auto_start=True)
50
+ run_startup_preamble(r'C:\app\synodic.exe')
51
+
52
+ mock_register.assert_called_once_with(r'C:\app\synodic.exe')
53
+ mock_remove.assert_not_called()
54
+
55
+ @staticmethod
56
+ def test_removes_startup_when_auto_start_false() -> None:
57
+ """remove_startup is called when auto_start is False."""
58
+ with (
59
+ patch(f'{_MODULE}.seed_user_config_from_build'),
60
+ patch(f'{_MODULE}.register_protocol'),
61
+ patch(f'{_MODULE}.resolve_config') as mock_resolve,
62
+ patch(f'{_MODULE}.register_startup') as mock_register,
63
+ patch(f'{_MODULE}.remove_startup') as mock_remove,
64
+ ):
65
+ mock_resolve.return_value = MagicMock(auto_start=False)
66
+ run_startup_preamble(r'C:\app\synodic.exe')
67
+
68
+ mock_remove.assert_called_once()
69
+ mock_register.assert_not_called()
70
+
71
+ @staticmethod
72
+ def test_defaults_exe_path_to_sys_executable() -> None:
73
+ """When exe_path is None, sys.executable is used."""
74
+ with (
75
+ patch(f'{_MODULE}.seed_user_config_from_build'),
76
+ patch(f'{_MODULE}.register_protocol') as mock_proto,
77
+ patch(f'{_MODULE}.resolve_config') as mock_resolve,
78
+ patch(f'{_MODULE}.register_startup') as mock_register,
79
+ patch(f'{_MODULE}.remove_startup'),
80
+ patch(f'{_MODULE}.sys') as mock_sys,
81
+ ):
82
+ mock_sys.executable = r'C:\Python\python.exe'
83
+ mock_resolve.return_value = MagicMock(auto_start=True)
84
+ run_startup_preamble()
85
+
86
+ mock_proto.assert_called_once_with(r'C:\Python\python.exe')
87
+ mock_register.assert_called_once_with(r'C:\Python\python.exe')
88
+
89
+ @staticmethod
90
+ def test_idempotent_on_second_call() -> None:
91
+ """A second call is a no-op; the preamble runs only once."""
92
+ with (
93
+ patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed,
94
+ patch(f'{_MODULE}.register_protocol'),
95
+ patch(f'{_MODULE}.resolve_config') as mock_resolve,
96
+ patch(f'{_MODULE}.register_startup'),
97
+ patch(f'{_MODULE}.remove_startup'),
98
+ ):
99
+ mock_resolve.return_value = MagicMock(auto_start=True)
100
+ run_startup_preamble(r'C:\app\synodic.exe')
101
+ run_startup_preamble(r'C:\app\synodic.exe')
102
+
103
+ mock_seed.assert_called_once()
@@ -6,6 +6,7 @@ import pytest
6
6
  import velopack
7
7
  from packaging.version import Version
8
8
 
9
+ import synodic_client.updater as updater_mod
9
10
  from synodic_client.updater import (
10
11
  GITHUB_REPO_URL,
11
12
  UpdateChannel,
@@ -416,6 +417,12 @@ class TestUpdaterApplyUpdate:
416
417
  class TestInitializeVelopack:
417
418
  """Tests for initialize_velopack function."""
418
419
 
420
+ @staticmethod
421
+ @pytest.fixture(autouse=True)
422
+ def _reset_velopack_guard() -> None:
423
+ """Reset the idempotency guard before each test."""
424
+ updater_mod._velopack_initialized = False
425
+
419
426
  @staticmethod
420
427
  def test_initialize_success() -> None:
421
428
  """Verify initialize_velopack calls App().run()."""
@@ -434,6 +441,15 @@ class TestInitializeVelopack:
434
441
  # Should not raise
435
442
  initialize_velopack()
436
443
 
444
+ @staticmethod
445
+ def test_idempotent_on_second_call() -> None:
446
+ """A second call is a no-op; Velopack is initialised only once."""
447
+ mock_app = MagicMock(spec=velopack.App)
448
+ with patch('synodic_client.updater.velopack.App', return_value=mock_app) as mock_app_class:
449
+ initialize_velopack()
450
+ initialize_velopack()
451
+ mock_app_class.assert_called_once()
452
+
437
453
 
438
454
  class TestGetVelopackManager:
439
455
  """Tests for _get_velopack_manager install detection via the SDK."""
@@ -0,0 +1,240 @@
1
+ """Tests for Windows auto-startup registration."""
2
+
3
+ import winreg
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from synodic_client.startup import (
7
+ APPROVED_ENABLED,
8
+ RUN_KEY_PATH,
9
+ STARTUP_APPROVED_KEY_PATH,
10
+ STARTUP_VALUE_NAME,
11
+ is_startup_registered,
12
+ register_startup,
13
+ remove_startup,
14
+ )
15
+
16
+
17
+ class TestRegisterStartup:
18
+ """Tests for register_startup."""
19
+
20
+ @staticmethod
21
+ def test_writes_registry_value() -> None:
22
+ """Verify correct registry value is written on Windows."""
23
+ mock_key = MagicMock()
24
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
25
+ mock_key.__exit__ = MagicMock(return_value=False)
26
+
27
+ with (
28
+ patch.object(winreg, 'OpenKey', return_value=mock_key) as mock_open,
29
+ patch.object(winreg, 'SetValueEx') as mock_set,
30
+ patch.object(winreg, 'CreateKey', return_value=mock_key),
31
+ ):
32
+ register_startup(r'C:\Program Files\Synodic\synodic.exe')
33
+
34
+ mock_open.assert_called_once_with(
35
+ winreg.HKEY_CURRENT_USER,
36
+ RUN_KEY_PATH,
37
+ 0,
38
+ winreg.KEY_SET_VALUE,
39
+ )
40
+ mock_set.assert_any_call(
41
+ mock_key,
42
+ STARTUP_VALUE_NAME,
43
+ 0,
44
+ winreg.REG_SZ,
45
+ r'"C:\Program Files\Synodic\synodic.exe"',
46
+ )
47
+
48
+ @staticmethod
49
+ def test_writes_startup_approved_enabled() -> None:
50
+ """Verify the StartupApproved enabled flag is written."""
51
+ mock_run_key = MagicMock()
52
+ mock_run_key.__enter__ = MagicMock(return_value=mock_run_key)
53
+ mock_run_key.__exit__ = MagicMock(return_value=False)
54
+
55
+ mock_approved_key = MagicMock()
56
+ mock_approved_key.__enter__ = MagicMock(return_value=mock_approved_key)
57
+ mock_approved_key.__exit__ = MagicMock(return_value=False)
58
+
59
+ with (
60
+ patch.object(winreg, 'OpenKey', return_value=mock_run_key),
61
+ patch.object(winreg, 'SetValueEx') as mock_set,
62
+ patch.object(winreg, 'CreateKey', return_value=mock_approved_key) as mock_create,
63
+ ):
64
+ register_startup(r'C:\synodic.exe')
65
+
66
+ mock_create.assert_called_once_with(
67
+ winreg.HKEY_CURRENT_USER,
68
+ STARTUP_APPROVED_KEY_PATH,
69
+ )
70
+ mock_set.assert_any_call(
71
+ mock_approved_key,
72
+ STARTUP_VALUE_NAME,
73
+ 0,
74
+ winreg.REG_BINARY,
75
+ APPROVED_ENABLED,
76
+ )
77
+
78
+ @staticmethod
79
+ def test_noop_on_non_windows() -> None:
80
+ """Verify register_startup is a no-op on non-Windows platforms."""
81
+ with patch('synodic_client.startup.sys') as mock_sys:
82
+ mock_sys.platform = 'linux'
83
+ register_startup('/usr/bin/synodic')
84
+
85
+
86
+ class TestRemoveStartup:
87
+ """Tests for remove_startup."""
88
+
89
+ @staticmethod
90
+ def test_deletes_registry_value() -> None:
91
+ """Verify the startup value is deleted."""
92
+ mock_key = MagicMock()
93
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
94
+ mock_key.__exit__ = MagicMock(return_value=False)
95
+
96
+ with (
97
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
98
+ patch.object(winreg, 'DeleteValue') as mock_delete,
99
+ ):
100
+ remove_startup()
101
+
102
+ mock_delete.assert_any_call(mock_key, STARTUP_VALUE_NAME)
103
+
104
+ @staticmethod
105
+ def test_clears_startup_approved() -> None:
106
+ """Verify the StartupApproved flag is also deleted."""
107
+ mock_run_key = MagicMock()
108
+ mock_run_key.__enter__ = MagicMock(return_value=mock_run_key)
109
+ mock_run_key.__exit__ = MagicMock(return_value=False)
110
+
111
+ mock_approved_key = MagicMock()
112
+ mock_approved_key.__enter__ = MagicMock(return_value=mock_approved_key)
113
+ mock_approved_key.__exit__ = MagicMock(return_value=False)
114
+
115
+ def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) -> MagicMock:
116
+ if 'Explorer' in path:
117
+ return mock_approved_key
118
+ return mock_run_key
119
+
120
+ with (
121
+ patch.object(winreg, 'OpenKey', side_effect=_open_key_side_effect),
122
+ patch.object(winreg, 'DeleteValue') as mock_delete,
123
+ ):
124
+ remove_startup()
125
+
126
+ # Both the Run and StartupApproved values should be deleted
127
+ expected_delete_count = 2
128
+ assert mock_delete.call_count == expected_delete_count
129
+ mock_delete.assert_any_call(mock_run_key, STARTUP_VALUE_NAME)
130
+ mock_delete.assert_any_call(mock_approved_key, STARTUP_VALUE_NAME)
131
+
132
+ @staticmethod
133
+ def test_handles_missing_value_gracefully() -> None:
134
+ """Verify no error when startup value doesn't exist."""
135
+ mock_key = MagicMock()
136
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
137
+ mock_key.__exit__ = MagicMock(return_value=False)
138
+
139
+ with (
140
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
141
+ patch.object(winreg, 'DeleteValue', side_effect=FileNotFoundError),
142
+ ):
143
+ # Should not raise
144
+ remove_startup()
145
+
146
+ @staticmethod
147
+ def test_noop_on_non_windows() -> None:
148
+ """Verify remove_startup is a no-op on non-Windows platforms."""
149
+ with patch('synodic_client.startup.sys') as mock_sys:
150
+ mock_sys.platform = 'linux'
151
+ remove_startup()
152
+
153
+
154
+ class TestIsStartupRegistered:
155
+ """Tests for is_startup_registered."""
156
+
157
+ @staticmethod
158
+ def test_returns_true_when_present() -> None:
159
+ """Verify True when the value exists and no approval override."""
160
+ mock_key = MagicMock()
161
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
162
+ mock_key.__exit__ = MagicMock(return_value=False)
163
+
164
+ def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) -> MagicMock:
165
+ return mock_key
166
+
167
+ def _query_side_effect(key: MagicMock, name: str) -> tuple[object, int]:
168
+ # Run key exists; StartupApproved key raises FileNotFoundError
169
+ # (no override → treated as enabled)
170
+ raise FileNotFoundError
171
+
172
+ with (
173
+ patch.object(winreg, 'OpenKey', side_effect=_open_key_side_effect),
174
+ patch.object(
175
+ winreg,
176
+ 'QueryValueEx',
177
+ side_effect=[
178
+ (r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query
179
+ FileNotFoundError, # StartupApproved query
180
+ ],
181
+ ),
182
+ ):
183
+ assert is_startup_registered() is True
184
+
185
+ @staticmethod
186
+ def test_returns_true_when_startup_approved_enabled() -> None:
187
+ """Verify True when the StartupApproved byte is 0x02 (enabled)."""
188
+ mock_key = MagicMock()
189
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
190
+ mock_key.__exit__ = MagicMock(return_value=False)
191
+
192
+ enabled_data = b'\x02' + b'\x00' * 11
193
+
194
+ with (
195
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
196
+ patch.object(
197
+ winreg,
198
+ 'QueryValueEx',
199
+ side_effect=[
200
+ (r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query
201
+ (enabled_data, winreg.REG_BINARY), # StartupApproved query
202
+ ],
203
+ ),
204
+ ):
205
+ assert is_startup_registered() is True
206
+
207
+ @staticmethod
208
+ def test_returns_false_when_startup_approved_disabled() -> None:
209
+ """Verify False when the StartupApproved byte is 0x03 (disabled)."""
210
+ mock_key = MagicMock()
211
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
212
+ mock_key.__exit__ = MagicMock(return_value=False)
213
+
214
+ disabled_data = b'\x03' + b'\x00' * 11
215
+
216
+ with (
217
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
218
+ patch.object(
219
+ winreg,
220
+ 'QueryValueEx',
221
+ side_effect=[
222
+ (r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query
223
+ (disabled_data, winreg.REG_BINARY), # StartupApproved query
224
+ ],
225
+ ),
226
+ ):
227
+ assert is_startup_registered() is False
228
+
229
+ @staticmethod
230
+ def test_returns_false_when_missing() -> None:
231
+ """Verify False when the value does not exist."""
232
+ mock_key = MagicMock()
233
+ mock_key.__enter__ = MagicMock(return_value=mock_key)
234
+ mock_key.__exit__ = MagicMock(return_value=False)
235
+
236
+ with (
237
+ patch.object(winreg, 'OpenKey', return_value=mock_key),
238
+ patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
239
+ ):
240
+ assert is_startup_registered() is False
@@ -1 +0,0 @@
1
- __version__ = '0.0.1.dev42'
@@ -1,91 +0,0 @@
1
- r"""Windows auto-startup registration via the registry.
2
-
3
- Manages a value under ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
4
- so the application launches automatically when the user logs in.
5
-
6
- Other platforms are stubbed with no-op implementations, matching the
7
- approach in :mod:`synodic_client.protocol`.
8
- """
9
-
10
- import logging
11
- import sys
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
- STARTUP_VALUE_NAME = 'SynodicClient'
16
- """Registry value name used in the ``Run`` key."""
17
-
18
- RUN_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Run'
19
-
20
-
21
- if sys.platform == 'win32':
22
- import winreg
23
-
24
- def register_startup(exe_path: str) -> None:
25
- r"""Register the application to start automatically on login.
26
-
27
- Writes a value to ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
28
- pointing to *exe_path*. Calling this repeatedly is safe and will
29
- update the path (useful after Velopack relocates the executable).
30
-
31
- Args:
32
- exe_path: Absolute path to the application executable.
33
- """
34
- try:
35
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
36
- winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_SZ, f'"{exe_path}"')
37
- logger.info('Registered auto-startup -> %s', exe_path)
38
- except OSError:
39
- logger.exception('Failed to register auto-startup')
40
-
41
- def remove_startup() -> None:
42
- """Remove the auto-startup registration.
43
-
44
- Silently succeeds if the value does not exist.
45
- """
46
- try:
47
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
48
- winreg.DeleteValue(key, STARTUP_VALUE_NAME)
49
- logger.info('Removed auto-startup registration')
50
- except FileNotFoundError:
51
- logger.debug('Auto-startup registration not found, nothing to remove')
52
- except OSError:
53
- logger.exception('Failed to remove auto-startup registration')
54
-
55
- def is_startup_registered() -> bool:
56
- """Check whether the auto-startup value is currently present.
57
-
58
- Returns:
59
- ``True`` if the ``Run`` key contains the startup value.
60
- """
61
- try:
62
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
63
- winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
64
- return True
65
- except FileNotFoundError:
66
- return False
67
- except OSError:
68
- logger.exception('Failed to query auto-startup registration')
69
- return False
70
-
71
- else:
72
-
73
- def register_startup(exe_path: str) -> None:
74
- """Register auto-startup (no-op on non-Windows).
75
-
76
- Args:
77
- exe_path: Absolute path to the application executable.
78
- """
79
- logger.warning('Auto-startup registration is only supported on Windows (current: %s)', sys.platform)
80
-
81
- def remove_startup() -> None:
82
- """Remove auto-startup registration (no-op on non-Windows)."""
83
- logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform)
84
-
85
- def is_startup_registered() -> bool:
86
- """Check auto-startup registration (always ``False`` on non-Windows).
87
-
88
- Returns:
89
- ``False``.
90
- """
91
- return False
@@ -1,121 +0,0 @@
1
- """Tests for Windows auto-startup registration."""
2
-
3
- import winreg
4
- from unittest.mock import MagicMock, patch
5
-
6
- from synodic_client.startup import (
7
- RUN_KEY_PATH,
8
- STARTUP_VALUE_NAME,
9
- is_startup_registered,
10
- register_startup,
11
- remove_startup,
12
- )
13
-
14
-
15
- class TestRegisterStartup:
16
- """Tests for register_startup."""
17
-
18
- @staticmethod
19
- def test_writes_registry_value() -> None:
20
- """Verify correct registry value is written on Windows."""
21
- mock_key = MagicMock()
22
- mock_key.__enter__ = MagicMock(return_value=mock_key)
23
- mock_key.__exit__ = MagicMock(return_value=False)
24
-
25
- with (
26
- patch.object(winreg, 'OpenKey', return_value=mock_key) as mock_open,
27
- patch.object(winreg, 'SetValueEx') as mock_set,
28
- ):
29
- register_startup(r'C:\Program Files\Synodic\synodic.exe')
30
-
31
- mock_open.assert_called_once_with(
32
- winreg.HKEY_CURRENT_USER,
33
- RUN_KEY_PATH,
34
- 0,
35
- winreg.KEY_SET_VALUE,
36
- )
37
- mock_set.assert_called_once_with(
38
- mock_key,
39
- STARTUP_VALUE_NAME,
40
- 0,
41
- winreg.REG_SZ,
42
- r'"C:\Program Files\Synodic\synodic.exe"',
43
- )
44
-
45
- @staticmethod
46
- def test_noop_on_non_windows() -> None:
47
- """Verify register_startup is a no-op on non-Windows platforms."""
48
- with patch('synodic_client.startup.sys') as mock_sys:
49
- mock_sys.platform = 'linux'
50
- register_startup('/usr/bin/synodic')
51
-
52
-
53
- class TestRemoveStartup:
54
- """Tests for remove_startup."""
55
-
56
- @staticmethod
57
- def test_deletes_registry_value() -> None:
58
- """Verify the startup value is deleted."""
59
- mock_key = MagicMock()
60
- mock_key.__enter__ = MagicMock(return_value=mock_key)
61
- mock_key.__exit__ = MagicMock(return_value=False)
62
-
63
- with (
64
- patch.object(winreg, 'OpenKey', return_value=mock_key),
65
- patch.object(winreg, 'DeleteValue') as mock_delete,
66
- ):
67
- remove_startup()
68
-
69
- mock_delete.assert_called_once_with(mock_key, STARTUP_VALUE_NAME)
70
-
71
- @staticmethod
72
- def test_handles_missing_value_gracefully() -> None:
73
- """Verify no error when startup value doesn't exist."""
74
- mock_key = MagicMock()
75
- mock_key.__enter__ = MagicMock(return_value=mock_key)
76
- mock_key.__exit__ = MagicMock(return_value=False)
77
-
78
- with (
79
- patch.object(winreg, 'OpenKey', return_value=mock_key),
80
- patch.object(winreg, 'DeleteValue', side_effect=FileNotFoundError),
81
- ):
82
- # Should not raise
83
- remove_startup()
84
-
85
- @staticmethod
86
- def test_noop_on_non_windows() -> None:
87
- """Verify remove_startup is a no-op on non-Windows platforms."""
88
- with patch('synodic_client.startup.sys') as mock_sys:
89
- mock_sys.platform = 'linux'
90
- remove_startup()
91
-
92
-
93
- class TestIsStartupRegistered:
94
- """Tests for is_startup_registered."""
95
-
96
- @staticmethod
97
- def test_returns_true_when_present() -> None:
98
- """Verify True when the value exists."""
99
- mock_key = MagicMock()
100
- mock_key.__enter__ = MagicMock(return_value=mock_key)
101
- mock_key.__exit__ = MagicMock(return_value=False)
102
-
103
- with (
104
- patch.object(winreg, 'OpenKey', return_value=mock_key),
105
- patch.object(winreg, 'QueryValueEx', return_value=(r'"C:\synodic.exe"', winreg.REG_SZ)),
106
- ):
107
- assert is_startup_registered() is True
108
-
109
- @staticmethod
110
- def test_returns_false_when_missing() -> None:
111
- """Verify False when the value does not exist."""
112
- mock_key = MagicMock()
113
- mock_key.__enter__ = MagicMock(return_value=mock_key)
114
- mock_key.__exit__ = MagicMock(return_value=False)
115
-
116
- with (
117
- patch.object(winreg, 'OpenKey', return_value=mock_key),
118
- patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
119
- ):
120
- assert is_startup_registered() is False
121
- assert is_startup_registered() is False