From 99564bfdebc7a3b5197885c3d17fed0e53236870 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 3 Mar 2026 11:14:09 -0800 Subject: [PATCH 1/2] Fix + Simplify Startup --- synodic_client/application/bootstrap.py | 16 +-- synodic_client/application/init.py | 61 +++++++++++ synodic_client/application/qt.py | 18 +--- synodic_client/startup.py | 79 ++++++++++++-- synodic_client/updater.py | 10 +- tests/unit/test_init.py | 104 +++++++++++++++++++ tests/unit/test_updater.py | 17 ++++ tests/unit/windows/test_startup.py | 130 ++++++++++++++++++++++-- 8 files changed, 395 insertions(+), 40 deletions(-) create mode 100644 synodic_client/application/init.py create mode 100644 tests/unit/test_init.py diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py index d7b02ba..c7c44f9 100644 --- a/synodic_client/application/bootstrap.py +++ b/synodic_client/application/bootstrap.py @@ -9,7 +9,7 @@ 1. stdlib + config (pure-Python, fast) 2. configure_logging() — now Qt-free 3. initialize_velopack() — hooks run with logging active - 4. register_protocol() — stdlib only + 4. run_startup_preamble() — protocol, config seed, auto-startup 5. import qt.application — PySide6 / porringer loaded here """ @@ -17,9 +17,6 @@ from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging -from synodic_client.protocol import register_protocol -from synodic_client.resolution import resolve_config, seed_user_config_from_build -from synodic_client.startup import register_startup, remove_startup from synodic_client.updater import initialize_velopack _PROTOCOL_SCHEME = 'synodic' @@ -32,16 +29,9 @@ initialize_velopack() if not _dev_mode: - # Seed user config from the build config (one-time propagation). - seed_user_config_from_build() + from synodic_client.application.init import run_startup_preamble - register_protocol(sys.executable) - - _config = resolve_config() - if _config.auto_start: - register_startup(sys.executable) - else: - remove_startup() + run_startup_preamble(sys.executable) # Heavy imports happen here — PySide6, porringer, etc. from synodic_client.application.qt import application diff --git a/synodic_client/application/init.py b/synodic_client/application/init.py new file mode 100644 index 0000000..709dd65 --- /dev/null +++ b/synodic_client/application/init.py @@ -0,0 +1,61 @@ +"""Shared startup preamble for frozen and CLI entry points. + +Encapsulates the one-time initialisation that both +:mod:`synodic_client.application.bootstrap` (PyInstaller) and +:mod:`synodic_client.application.qt` (CLI / dev-script) need to +perform before the GUI event loop starts: + +1. Seed user config from the build config (one-time propagation). +2. Register the ``synodic://`` URI protocol handler. +3. Synchronise the Windows auto-startup registry entry with the + persisted ``auto_start`` preference. + +Heavy dependencies (PySide6, porringer) are **not** imported here so +that the bootstrap path can call this before loading them. +""" + +import logging +import sys + +from synodic_client.protocol import register_protocol +from synodic_client.resolution import resolve_config, seed_user_config_from_build +from synodic_client.startup import register_startup, remove_startup + +logger = logging.getLogger(__name__) + +_preamble_done = False + + +def run_startup_preamble(exe_path: str | None = None) -> None: + """Run the shared startup preamble for non-dev-mode launches. + + Both the frozen entry point + (:mod:`~synodic_client.application.bootstrap`) and the CLI entry + point (:func:`~synodic_client.application.qt.application`) call + this unconditionally. An internal guard ensures the work only + executes once per process. + + Args: + exe_path: Absolute path to the application executable. Defaults + to ``sys.executable`` when not supplied. + """ + global _preamble_done # noqa: PLW0603 + if _preamble_done: + return + _preamble_done = True + + if exe_path is None: + exe_path = sys.executable + + # Seed user config from the build config (one-time propagation). + seed_user_config_from_build() + + register_protocol(exe_path) + + config = resolve_config() + if config.auto_start: + register_startup(exe_path) + else: + remove_startup() + + logger.info('Startup preamble complete (auto_start=%s)', config.auto_start) diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index bc1e2ff..c3d6c4a 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -23,15 +23,12 @@ from synodic_client.client import Client from synodic_client.config import set_dev_mode from synodic_client.logging import configure_logging -from synodic_client.protocol import register_protocol from synodic_client.resolution import ( ResolvedConfig, resolve_config, resolve_update_config, resolve_version, - seed_user_config_from_build, ) -from synodic_client.startup import register_startup, remove_startup from synodic_client.updater import initialize_velopack @@ -139,20 +136,13 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None: _install_exception_hook(logger) if not dev_mode: - # Initialize Velopack early, before any UI. - # Console window suppression for subprocesses is handled by the - # PyInstaller runtime hook (rthook_no_console.py). + # All three functions are idempotent — safe to call even when + # bootstrap.py has already executed them before heavy imports. initialize_velopack() - register_protocol(sys.executable) - # Seed user config from build config (one-time propagation). - seed_user_config_from_build() + from synodic_client.application.init import run_startup_preamble - startup_config = resolve_config() - if startup_config.auto_start: - register_startup(sys.executable) - else: - remove_startup() + run_startup_preamble(sys.executable) if uri: logger.info('Received URI: %s', uri) diff --git a/synodic_client/startup.py b/synodic_client/startup.py index b935f55..7bda020 100644 --- a/synodic_client/startup.py +++ b/synodic_client/startup.py @@ -3,6 +3,18 @@ Manages a value under ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run`` so the application launches automatically when the user logs in. +Windows also maintains a parallel +``HKCU\...\Explorer\StartupApproved\Run`` key where each entry is a +12-byte ``REG_BINARY`` value. Byte 0 controls the enabled state: + +* ``0x02`` — **enabled** (Windows will honour the ``Run`` entry). +* ``0x03`` — **disabled** (entry hidden from startup by Task Manager / + Settings → Startup Apps). + +When registering or removing auto-startup we synchronise *both* keys +so that a user toggling the setting in-app overrides any prior +Task-Manager disable. + Other platforms are stubbed with no-op implementations, matching the approach in :mod:`synodic_client.protocol`. """ @@ -17,6 +29,13 @@ RUN_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Run' +STARTUP_APPROVED_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run' +"""Registry key where Windows stores per-entry enabled/disabled flags.""" + +# 12-byte REG_BINARY payloads for the StartupApproved value. +_APPROVED_ENABLED: bytes = b'\x02' + b'\x00' * 11 +_APPROVED_DISABLED_BYTE: int = 0x03 + if sys.platform == 'win32': import winreg @@ -25,8 +44,12 @@ def register_startup(exe_path: str) -> None: r"""Register the application to start automatically on login. Writes a value to ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run`` - pointing to *exe_path*. Calling this repeatedly is safe and will - update the path (useful after Velopack relocates the executable). + pointing to *exe_path* **and** writes an *enabled* flag to the + corresponding ``StartupApproved\Run`` key so that a previous + Task-Manager disable is overridden. + + Calling this repeatedly is safe and will update the path (useful + after Velopack relocates the executable). Args: exe_path: Absolute path to the application executable. @@ -38,10 +61,20 @@ def register_startup(exe_path: str) -> None: except OSError: logger.exception('Failed to register auto-startup') + # Ensure Windows considers the entry enabled even if the user + # previously disabled it via Task Manager. + try: + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH) as key: + winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_BINARY, _APPROVED_ENABLED) + logger.debug('Wrote StartupApproved enabled flag') + except OSError: + logger.exception('Failed to write StartupApproved enabled flag') + def remove_startup() -> None: """Remove the auto-startup registration. - Silently succeeds if the value does not exist. + Removes both the ``Run`` value and any ``StartupApproved`` flag. + Silently succeeds if the values do not exist. """ try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key: @@ -52,22 +85,56 @@ def remove_startup() -> None: except OSError: logger.exception('Failed to remove auto-startup registration') + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.DeleteValue(key, STARTUP_VALUE_NAME) + logger.debug('Removed StartupApproved flag') + except FileNotFoundError: + logger.debug('StartupApproved flag not found, nothing to remove') + except OSError: + logger.exception('Failed to remove StartupApproved flag') + def is_startup_registered() -> bool: - """Check whether the auto-startup value is currently present. + """Check whether auto-startup is both present **and** enabled. + + Returns ``True`` only when the ``Run`` value exists and Windows + has not disabled it via ``StartupApproved\Run``. Returns: - ``True`` if the ``Run`` key contains the startup value. + ``True`` if the application will auto-start on login. """ + # 1. Check the Run key exists at all. try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: winreg.QueryValueEx(key, STARTUP_VALUE_NAME) - return True except FileNotFoundError: return False except OSError: logger.exception('Failed to query auto-startup registration') return False + # 2. Check the StartupApproved override. If the key/value is + # absent the entry is considered enabled (Windows only writes + # this key when the user explicitly disables/enables via Task + # Manager or Settings). + try: + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_QUERY_VALUE + ) as key: + data, _ = winreg.QueryValueEx(key, STARTUP_VALUE_NAME) + if isinstance(data, bytes) and len(data) >= 1 and data[0] == _APPROVED_DISABLED_BYTE: + logger.debug('Auto-startup is disabled via StartupApproved') + return False + except FileNotFoundError: + # No approval override → treat as enabled. + pass + except OSError: + logger.exception('Failed to query StartupApproved flag') + + return True + else: def register_startup(exe_path: str) -> None: diff --git a/synodic_client/updater.py b/synodic_client/updater.py index f91b118..a088c6b 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -433,6 +433,9 @@ def _on_before_uninstall(version: str) -> None: logger.warning('Auto-startup removal failed during uninstall hook', exc_info=True) +_velopack_initialized = False + + def initialize_velopack() -> None: """Initialize Velopack at application startup. @@ -440,13 +443,18 @@ def initialize_velopack() -> None: before any UI is shown. Velopack may need to perform cleanup or apply pending updates. - Protocol registration happens on every app launch (see ``qt.application``). + Safe to call more than once — subsequent calls are no-ops. .. note:: The SDK's callback hooks only accept ``PyCFunction`` — add an uninstall hook here when that is fixed upstream. """ + global _velopack_initialized # noqa: PLW0603 + if _velopack_initialized: + return + _velopack_initialized = True + logger.info('Initializing Velopack (exe=%s)', sys.executable) try: app = velopack.App() diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py new file mode 100644 index 0000000..96b39f1 --- /dev/null +++ b/tests/unit/test_init.py @@ -0,0 +1,104 @@ +"""Tests for the shared startup preamble.""" + +from unittest.mock import MagicMock, patch + +import pytest + +import synodic_client.application.init as init_mod +from synodic_client.application.init import run_startup_preamble + + +_MODULE = 'synodic_client.application.init' + + +@pytest.fixture(autouse=True) +def _reset_preamble_guard() -> None: + """Reset the idempotency guard before each test.""" + init_mod._preamble_done = False + + +class TestRunStartupPreamble: + """Verify that run_startup_preamble orchestrates the correct calls.""" + + @staticmethod + def test_calls_seed_and_register_protocol() -> None: + """Seed, protocol registration, and config resolution are invoked.""" + with ( + patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed, + patch(f'{_MODULE}.register_protocol') as mock_proto, + patch(f'{_MODULE}.resolve_config') as mock_resolve, + patch(f'{_MODULE}.register_startup'), + patch(f'{_MODULE}.remove_startup'), + ): + mock_resolve.return_value = MagicMock(auto_start=True) + run_startup_preamble(r'C:\app\synodic.exe') + + mock_seed.assert_called_once() + mock_proto.assert_called_once_with(r'C:\app\synodic.exe') + mock_resolve.assert_called_once() + + @staticmethod + def test_registers_startup_when_auto_start_true() -> None: + """register_startup is called when auto_start is True.""" + with ( + patch(f'{_MODULE}.seed_user_config_from_build'), + patch(f'{_MODULE}.register_protocol'), + patch(f'{_MODULE}.resolve_config') as mock_resolve, + patch(f'{_MODULE}.register_startup') as mock_register, + patch(f'{_MODULE}.remove_startup') as mock_remove, + ): + mock_resolve.return_value = MagicMock(auto_start=True) + run_startup_preamble(r'C:\app\synodic.exe') + + mock_register.assert_called_once_with(r'C:\app\synodic.exe') + mock_remove.assert_not_called() + + @staticmethod + def test_removes_startup_when_auto_start_false() -> None: + """remove_startup is called when auto_start is False.""" + with ( + patch(f'{_MODULE}.seed_user_config_from_build'), + patch(f'{_MODULE}.register_protocol'), + patch(f'{_MODULE}.resolve_config') as mock_resolve, + patch(f'{_MODULE}.register_startup') as mock_register, + patch(f'{_MODULE}.remove_startup') as mock_remove, + ): + mock_resolve.return_value = MagicMock(auto_start=False) + run_startup_preamble(r'C:\app\synodic.exe') + + mock_remove.assert_called_once() + mock_register.assert_not_called() + + @staticmethod + def test_defaults_exe_path_to_sys_executable() -> None: + """When exe_path is None, sys.executable is used.""" + with ( + patch(f'{_MODULE}.seed_user_config_from_build'), + patch(f'{_MODULE}.register_protocol') as mock_proto, + patch(f'{_MODULE}.resolve_config') as mock_resolve, + patch(f'{_MODULE}.register_startup') as mock_register, + patch(f'{_MODULE}.remove_startup'), + patch(f'{_MODULE}.sys') as mock_sys, + ): + mock_sys.executable = r'C:\Python\python.exe' + mock_resolve.return_value = MagicMock(auto_start=True) + run_startup_preamble() + + mock_proto.assert_called_once_with(r'C:\Python\python.exe') + mock_register.assert_called_once_with(r'C:\Python\python.exe') + + @staticmethod + def test_idempotent_on_second_call() -> None: + """A second call is a no-op; the preamble runs only once.""" + with ( + patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed, + patch(f'{_MODULE}.register_protocol'), + patch(f'{_MODULE}.resolve_config') as mock_resolve, + patch(f'{_MODULE}.register_startup'), + patch(f'{_MODULE}.remove_startup'), + ): + mock_resolve.return_value = MagicMock(auto_start=True) + run_startup_preamble(r'C:\app\synodic.exe') + run_startup_preamble(r'C:\app\synodic.exe') + + mock_seed.assert_called_once() diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 0917c1f..1812b80 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -416,6 +416,14 @@ def test_apply_on_exit_no_restart(updater: Updater) -> None: class TestInitializeVelopack: """Tests for initialize_velopack function.""" + @staticmethod + @pytest.fixture(autouse=True) + def _reset_velopack_guard() -> None: + """Reset the idempotency guard before each test.""" + import synodic_client.updater as updater_mod + + updater_mod._velopack_initialized = False + @staticmethod def test_initialize_success() -> None: """Verify initialize_velopack calls App().run().""" @@ -434,6 +442,15 @@ def test_initialize_handles_exception() -> None: # Should not raise initialize_velopack() + @staticmethod + def test_idempotent_on_second_call() -> None: + """A second call is a no-op; Velopack is initialised only once.""" + mock_app = MagicMock(spec=velopack.App) + with patch('synodic_client.updater.velopack.App', return_value=mock_app) as mock_app_class: + initialize_velopack() + initialize_velopack() + mock_app_class.assert_called_once() + class TestGetVelopackManager: """Tests for _get_velopack_manager install detection via the SDK.""" diff --git a/tests/unit/windows/test_startup.py b/tests/unit/windows/test_startup.py index f221819..768d266 100644 --- a/tests/unit/windows/test_startup.py +++ b/tests/unit/windows/test_startup.py @@ -1,11 +1,13 @@ """Tests for Windows auto-startup registration.""" import winreg -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from synodic_client.startup import ( RUN_KEY_PATH, + STARTUP_APPROVED_KEY_PATH, STARTUP_VALUE_NAME, + _APPROVED_ENABLED, is_startup_registered, register_startup, remove_startup, @@ -25,6 +27,7 @@ def test_writes_registry_value() -> None: with ( patch.object(winreg, 'OpenKey', return_value=mock_key) as mock_open, patch.object(winreg, 'SetValueEx') as mock_set, + patch.object(winreg, 'CreateKey', return_value=mock_key), ): register_startup(r'C:\Program Files\Synodic\synodic.exe') @@ -34,7 +37,7 @@ def test_writes_registry_value() -> None: 0, winreg.KEY_SET_VALUE, ) - mock_set.assert_called_once_with( + mock_set.assert_any_call( mock_key, STARTUP_VALUE_NAME, 0, @@ -42,6 +45,36 @@ def test_writes_registry_value() -> None: r'"C:\Program Files\Synodic\synodic.exe"', ) + @staticmethod + def test_writes_startup_approved_enabled() -> None: + """Verify the StartupApproved enabled flag is written.""" + mock_run_key = MagicMock() + mock_run_key.__enter__ = MagicMock(return_value=mock_run_key) + mock_run_key.__exit__ = MagicMock(return_value=False) + + mock_approved_key = MagicMock() + mock_approved_key.__enter__ = MagicMock(return_value=mock_approved_key) + mock_approved_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_run_key), + patch.object(winreg, 'SetValueEx') as mock_set, + patch.object(winreg, 'CreateKey', return_value=mock_approved_key) as mock_create, + ): + register_startup(r'C:\synodic.exe') + + mock_create.assert_called_once_with( + winreg.HKEY_CURRENT_USER, + STARTUP_APPROVED_KEY_PATH, + ) + mock_set.assert_any_call( + mock_approved_key, + STARTUP_VALUE_NAME, + 0, + winreg.REG_BINARY, + _APPROVED_ENABLED, + ) + @staticmethod def test_noop_on_non_windows() -> None: """Verify register_startup is a no-op on non-Windows platforms.""" @@ -66,7 +99,34 @@ def test_deletes_registry_value() -> None: ): remove_startup() - mock_delete.assert_called_once_with(mock_key, STARTUP_VALUE_NAME) + mock_delete.assert_any_call(mock_key, STARTUP_VALUE_NAME) + + @staticmethod + def test_clears_startup_approved() -> None: + """Verify the StartupApproved flag is also deleted.""" + mock_run_key = MagicMock() + mock_run_key.__enter__ = MagicMock(return_value=mock_run_key) + mock_run_key.__exit__ = MagicMock(return_value=False) + + mock_approved_key = MagicMock() + mock_approved_key.__enter__ = MagicMock(return_value=mock_approved_key) + mock_approved_key.__exit__ = MagicMock(return_value=False) + + def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) -> MagicMock: + if 'Explorer' in path: + return mock_approved_key + return mock_run_key + + with ( + patch.object(winreg, 'OpenKey', side_effect=_open_key_side_effect), + patch.object(winreg, 'DeleteValue') as mock_delete, + ): + remove_startup() + + # Both the Run and StartupApproved values should be deleted + assert mock_delete.call_count == 2 + mock_delete.assert_any_call(mock_run_key, STARTUP_VALUE_NAME) + mock_delete.assert_any_call(mock_approved_key, STARTUP_VALUE_NAME) @staticmethod def test_handles_missing_value_gracefully() -> None: @@ -95,17 +155,76 @@ class TestIsStartupRegistered: @staticmethod def test_returns_true_when_present() -> None: - """Verify True when the value exists.""" + """Verify True when the value exists and no approval override.""" mock_key = MagicMock() mock_key.__enter__ = MagicMock(return_value=mock_key) mock_key.__exit__ = MagicMock(return_value=False) + def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) -> MagicMock: + return mock_key + + def _query_side_effect(key: MagicMock, name: str) -> tuple[object, int]: + # Run key exists; StartupApproved key raises FileNotFoundError + # (no override → treated as enabled) + raise FileNotFoundError + + with ( + patch.object(winreg, 'OpenKey', side_effect=_open_key_side_effect), + patch.object( + winreg, + 'QueryValueEx', + side_effect=[ + (r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query + FileNotFoundError, # StartupApproved query + ], + ), + ): + assert is_startup_registered() is True + + @staticmethod + def test_returns_true_when_startup_approved_enabled() -> None: + """Verify True when the StartupApproved byte is 0x02 (enabled).""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + enabled_data = b'\x02' + b'\x00' * 11 + with ( patch.object(winreg, 'OpenKey', return_value=mock_key), - patch.object(winreg, 'QueryValueEx', return_value=(r'"C:\synodic.exe"', winreg.REG_SZ)), + patch.object( + winreg, + 'QueryValueEx', + side_effect=[ + (r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query + (enabled_data, winreg.REG_BINARY), # StartupApproved query + ], + ), ): assert is_startup_registered() is True + @staticmethod + def test_returns_false_when_startup_approved_disabled() -> None: + """Verify False when the StartupApproved byte is 0x03 (disabled).""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + disabled_data = b'\x03' + b'\x00' * 11 + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object( + winreg, + 'QueryValueEx', + side_effect=[ + (r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query + (disabled_data, winreg.REG_BINARY), # StartupApproved query + ], + ), + ): + assert is_startup_registered() is False + @staticmethod def test_returns_false_when_missing() -> None: """Verify False when the value does not exist.""" @@ -118,4 +237,3 @@ def test_returns_false_when_missing() -> None: patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError), ): assert is_startup_registered() is False - assert is_startup_registered() is False From df98779d2a4ad59ced1fee22858a2dc280e5bd5a Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 3 Mar 2026 11:16:55 -0800 Subject: [PATCH 2/2] Lint Fixes --- synodic_client/application/qt.py | 4 +--- synodic_client/startup.py | 16 +++++++--------- tests/unit/test_init.py | 1 - tests/unit/test_updater.py | 3 +-- tests/unit/windows/test_startup.py | 9 +++++---- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index c3d6c4a..31ca467 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -15,6 +15,7 @@ from PySide6.QtWidgets import QApplication from synodic_client.application.icon import app_icon +from synodic_client.application.init import run_startup_preamble from synodic_client.application.instance import SingleInstance from synodic_client.application.screen.install import InstallPreviewWindow from synodic_client.application.screen.screen import Screen @@ -139,9 +140,6 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None: # All three functions are idempotent — safe to call even when # bootstrap.py has already executed them before heavy imports. initialize_velopack() - - from synodic_client.application.init import run_startup_preamble - run_startup_preamble(sys.executable) if uri: diff --git a/synodic_client/startup.py b/synodic_client/startup.py index 7bda020..252fc0c 100644 --- a/synodic_client/startup.py +++ b/synodic_client/startup.py @@ -33,7 +33,9 @@ """Registry key where Windows stores per-entry enabled/disabled flags.""" # 12-byte REG_BINARY payloads for the StartupApproved value. -_APPROVED_ENABLED: bytes = b'\x02' + b'\x00' * 11 +APPROVED_ENABLED: bytes = b'\x02' + b'\x00' * 11 +"""Enabled payload for the ``StartupApproved\\Run`` registry value.""" + _APPROVED_DISABLED_BYTE: int = 0x03 @@ -65,7 +67,7 @@ def register_startup(exe_path: str) -> None: # previously disabled it via Task Manager. try: with winreg.CreateKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH) as key: - winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_BINARY, _APPROVED_ENABLED) + winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_BINARY, APPROVED_ENABLED) logger.debug('Wrote StartupApproved enabled flag') except OSError: logger.exception('Failed to write StartupApproved enabled flag') @@ -86,9 +88,7 @@ def remove_startup() -> None: logger.exception('Failed to remove auto-startup registration') try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_SET_VALUE - ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key: winreg.DeleteValue(key, STARTUP_VALUE_NAME) logger.debug('Removed StartupApproved flag') except FileNotFoundError: @@ -97,7 +97,7 @@ def remove_startup() -> None: logger.exception('Failed to remove StartupApproved flag') def is_startup_registered() -> bool: - """Check whether auto-startup is both present **and** enabled. + r"""Check whether auto-startup is both present **and** enabled. Returns ``True`` only when the ``Run`` value exists and Windows has not disabled it via ``StartupApproved\Run``. @@ -120,9 +120,7 @@ def is_startup_registered() -> bool: # this key when the user explicitly disables/enables via Task # Manager or Settings). try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_QUERY_VALUE - ) as key: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: data, _ = winreg.QueryValueEx(key, STARTUP_VALUE_NAME) if isinstance(data, bytes) and len(data) >= 1 and data[0] == _APPROVED_DISABLED_BYTE: logger.debug('Auto-startup is disabled via StartupApproved') diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 96b39f1..dceed31 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -7,7 +7,6 @@ import synodic_client.application.init as init_mod from synodic_client.application.init import run_startup_preamble - _MODULE = 'synodic_client.application.init' diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 1812b80..7ec29cf 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -6,6 +6,7 @@ import velopack from packaging.version import Version +import synodic_client.updater as updater_mod from synodic_client.updater import ( GITHUB_REPO_URL, UpdateChannel, @@ -420,8 +421,6 @@ class TestInitializeVelopack: @pytest.fixture(autouse=True) def _reset_velopack_guard() -> None: """Reset the idempotency guard before each test.""" - import synodic_client.updater as updater_mod - updater_mod._velopack_initialized = False @staticmethod diff --git a/tests/unit/windows/test_startup.py b/tests/unit/windows/test_startup.py index 768d266..26a6dd2 100644 --- a/tests/unit/windows/test_startup.py +++ b/tests/unit/windows/test_startup.py @@ -1,13 +1,13 @@ """Tests for Windows auto-startup registration.""" import winreg -from unittest.mock import MagicMock, call, patch +from unittest.mock import MagicMock, patch from synodic_client.startup import ( + APPROVED_ENABLED, RUN_KEY_PATH, STARTUP_APPROVED_KEY_PATH, STARTUP_VALUE_NAME, - _APPROVED_ENABLED, is_startup_registered, register_startup, remove_startup, @@ -72,7 +72,7 @@ def test_writes_startup_approved_enabled() -> None: STARTUP_VALUE_NAME, 0, winreg.REG_BINARY, - _APPROVED_ENABLED, + APPROVED_ENABLED, ) @staticmethod @@ -124,7 +124,8 @@ def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) - remove_startup() # Both the Run and StartupApproved values should be deleted - assert mock_delete.call_count == 2 + expected_delete_count = 2 + assert mock_delete.call_count == expected_delete_count mock_delete.assert_any_call(mock_run_key, STARTUP_VALUE_NAME) mock_delete.assert_any_call(mock_approved_key, STARTUP_VALUE_NAME)