diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py index fe2e532..24bcb22 100644 --- a/synodic_client/application/bootstrap.py +++ b/synodic_client/application/bootstrap.py @@ -13,13 +13,25 @@ 5. import qt.application — PySide6 / porringer loaded here """ +import logging import sys - -from synodic_client.config import set_dev_mode -from synodic_client.logging import configure_logging -from synodic_client.protocol import extract_uri_from_args -from synodic_client.subprocess_patch import apply as _apply_subprocess_patch -from synodic_client.updater import initialize_velopack +import traceback + +try: + from synodic_client.config import set_dev_mode + from synodic_client.logging import configure_logging + from synodic_client.protocol import extract_uri_from_args + from synodic_client.subprocess_patch import apply as _apply_subprocess_patch + from synodic_client.updater import initialize_velopack +except Exception: + # Last-resort crash log when imports fail before logging is configured. + import os + + _fallback = os.path.join(os.environ.get('LOCALAPPDATA', '.'), 'Synodic', 'logs', 'bootstrap-crash.log') + os.makedirs(os.path.dirname(_fallback), exist_ok=True) + with open(_fallback, 'a', encoding='utf-8') as _f: # noqa: PTH123 + _f.write(traceback.format_exc()) + raise # Parse flags early so logging uses the right filename and level. _dev_mode = '--dev' in sys.argv[1:] @@ -28,6 +40,10 @@ _apply_subprocess_patch() configure_logging(debug=_debug) + +_logger = logging.getLogger(__name__) +_logger.info('Bootstrap started (exe=%s, argv=%s)', sys.executable, sys.argv) + initialize_velopack() if not _dev_mode: diff --git a/synodic_client/application/init.py b/synodic_client/application/init.py index 1de4043..79f5ba3 100644 --- a/synodic_client/application/init.py +++ b/synodic_client/application/init.py @@ -19,7 +19,7 @@ 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.startup import sync_startup logger = logging.getLogger(__name__) @@ -59,10 +59,6 @@ def run_startup_preamble(exe_path: str | None = None) -> None: register_protocol(exe_path) config = resolve_config() - if frozen: - if config.auto_start: - register_startup(exe_path) - else: - remove_startup() + sync_startup(exe_path, auto_start=config.auto_start) logger.info('Startup preamble complete (auto_start=%s, frozen=%s)', config.auto_start, frozen) diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py index 80b41f6..70add72 100644 --- a/synodic_client/application/screen/settings.py +++ b/synodic_client/application/screen/settings.py @@ -36,7 +36,7 @@ from synodic_client.application.update_model import UpdateModel from synodic_client.logging import log_path, set_debug_level from synodic_client.schema import GITHUB_REPO_URL -from synodic_client.startup import is_startup_registered, register_startup, remove_startup +from synodic_client.startup import is_startup_registered, sync_startup logger = logging.getLogger(__name__) @@ -382,11 +382,7 @@ def _on_auto_apply_changed(self, checked: bool) -> None: def _on_auto_start_changed(self, checked: bool) -> None: self._store.update(auto_start=checked) - if getattr(sys, 'frozen', False): - if checked: - register_startup(sys.executable) - else: - remove_startup() + sync_startup(sys.executable, auto_start=checked) def _on_debug_logging_changed(self, checked: bool) -> None: set_debug_level(enabled=checked) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index f544c1e..e6c24c4 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -3,6 +3,7 @@ import logging from typing import TYPE_CHECKING +from PySide6.QtCore import QTimer from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QApplication, @@ -54,7 +55,12 @@ def __init__( self.tray = QSystemTrayIcon() self.tray.setIcon(self.tray_icon) self.tray.activated.connect(self._on_tray_activated) - self.tray.setVisible(True) + + # At early Windows login the notification area may not be ready. + # Retry with back-off so the icon eventually appears. + self._tray_retry_count = 0 + self._tray_retry_timer: QTimer | None = None + self._show_tray_icon() self._build_menu(app, window) @@ -127,6 +133,37 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: self.tray.setContextMenu(self.menu) + # Maximum number of tray-visibility retries at startup. + _TRAY_MAX_RETRIES = 5 + # Delay between retries in milliseconds. + _TRAY_RETRY_DELAY_MS = 2000 + + def _show_tray_icon(self) -> None: + """Show the tray icon, retrying if the system tray is not ready.""" + if QSystemTrayIcon.isSystemTrayAvailable(): + self.tray.setVisible(True) + logger.debug('System tray icon shown') + return + + if self._tray_retry_count < self._TRAY_MAX_RETRIES: + self._tray_retry_count += 1 + logger.warning( + 'System tray not available, retrying (%d/%d)', + self._tray_retry_count, + self._TRAY_MAX_RETRIES, + ) + self._tray_retry_timer = QTimer() + self._tray_retry_timer.setSingleShot(True) + self._tray_retry_timer.timeout.connect(self._show_tray_icon) + self._tray_retry_timer.start(self._TRAY_RETRY_DELAY_MS) + else: + # Exhausted retries — show anyway as a best-effort fallback. + logger.warning( + 'System tray still not available after %d retries, forcing visibility', + self._TRAY_MAX_RETRIES, + ) + self.tray.setVisible(True) + def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: """Handle tray icon activation (e.g. double-click).""" if reason == QSystemTrayIcon.ActivationReason.DoubleClick: diff --git a/synodic_client/application/update_controller.py b/synodic_client/application/update_controller.py index 9b6aeb9..7a79198 100644 --- a/synodic_client/application/update_controller.py +++ b/synodic_client/application/update_controller.py @@ -14,6 +14,7 @@ import asyncio import logging +import sys from collections.abc import Callable from datetime import UTC, datetime from typing import TYPE_CHECKING @@ -34,6 +35,7 @@ resolve_update_config, ) from synodic_client.schema import UpdateInfo +from synodic_client.startup import sync_startup if TYPE_CHECKING: from synodic_client.application.config_store import ConfigStore @@ -413,6 +415,14 @@ def _apply_update(self, *, silent: bool = False) -> None: return try: + # Re-register the startup entry with the current exe path so + # the registry value stays valid even if Velopack relocates + # the binary during the update. The relaunched process will + # overwrite it again via run_startup_preamble, but this + # ensures the entry is never stale between the update and + # the next launch. + sync_startup(sys.executable, auto_start=self._store.config.auto_start) + self._pending_version = None self._client.apply_update_on_exit(restart=True, silent=silent) logger.info('Update scheduled — restarting application') diff --git a/synodic_client/startup.py b/synodic_client/startup.py index 252fc0c..068c994 100644 --- a/synodic_client/startup.py +++ b/synodic_client/startup.py @@ -96,6 +96,24 @@ def remove_startup() -> None: except OSError: logger.exception('Failed to remove StartupApproved flag') + def get_registered_startup_path() -> str | None: + r"""Return the executable path stored in the ``Run`` registry key. + + Returns: + The unquoted path string, or ``None`` when the value does + not exist or cannot be read. + """ + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key: + value, _ = winreg.QueryValueEx(key, STARTUP_VALUE_NAME) + # The value is stored as '""'; strip the quotes. + return value.strip('"') if isinstance(value, str) else None + except FileNotFoundError: + return None + except OSError: + logger.exception('Failed to read auto-startup path from registry') + return None + def is_startup_registered() -> bool: r"""Check whether auto-startup is both present **and** enabled. @@ -147,6 +165,14 @@ def remove_startup() -> None: """Remove auto-startup registration (no-op on non-Windows).""" logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform) + def get_registered_startup_path() -> str | None: + """Return the registered startup path (always ``None`` on non-Windows). + + Returns: + ``None``. + """ + return None + def is_startup_registered() -> bool: """Check auto-startup registration (always ``False`` on non-Windows). @@ -154,3 +180,34 @@ def is_startup_registered() -> bool: ``False``. """ return False + + +def sync_startup(exe_path: str, *, auto_start: bool) -> None: + """Synchronise the auto-startup registry state with the given preference. + + Registers or removes the startup entry and logs a warning when + the previously registered path differs from *exe_path* (stale + path after a Velopack update, for example). + + This is a no-op when ``sys.frozen`` is falsy (non-installed + builds never touch the registry). + + Args: + exe_path: Absolute path to the application executable. + auto_start: Whether auto-startup should be enabled. + """ + if not getattr(sys, 'frozen', False): + return + + registered_path = get_registered_startup_path() + if registered_path and registered_path != exe_path: + logger.warning( + 'Startup registry path mismatch: registered=%s, current=%s', + registered_path, + exe_path, + ) + + if auto_start: + register_startup(exe_path) + else: + remove_startup() diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py index 24e6f5b..2fe16c9 100644 --- a/tests/unit/qt/test_settings.py +++ b/tests/unit/qt/test_settings.py @@ -238,24 +238,23 @@ def test_tool_update_interval_change() -> None: @staticmethod def test_auto_start_registers_startup_when_frozen() -> None: - """Enabling auto-start calls register_startup in frozen builds.""" + """Enabling auto-start calls sync_startup.""" config = _make_config() window = _make_window(config) new_config = _make_config(auto_start=True) with ( patch.object(window._store, 'update', return_value=new_config), - patch('synodic_client.application.screen.settings.register_startup') as mock_register, + patch('synodic_client.application.screen.settings.sync_startup') as mock_sync, patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False), - patch('synodic_client.application.screen.settings.getattr', return_value=True), ): window._auto_start_check.setChecked(True) - mock_register.assert_called_once() + mock_sync.assert_called_once() @staticmethod def test_auto_start_removes_startup_when_frozen() -> None: - """Disabling auto-start calls remove_startup in frozen builds.""" + """Disabling auto-start calls sync_startup with auto_start=False.""" config = _make_config(auto_start=True) window = _make_window(config) # Manually set initial state without triggering signals @@ -266,31 +265,28 @@ def test_auto_start_removes_startup_when_frozen() -> None: new_config = _make_config(auto_start=False) with ( patch.object(window._store, 'update', return_value=new_config), - patch('synodic_client.application.screen.settings.remove_startup') as mock_remove, - patch('synodic_client.application.screen.settings.getattr', return_value=True), + patch('synodic_client.application.screen.settings.sync_startup') as mock_sync, ): window._auto_start_check.setChecked(False) - mock_remove.assert_called_once() + mock_sync.assert_called_once() @staticmethod def test_auto_start_skips_registry_when_not_frozen() -> None: - """Auto-start toggle persists config but skips registry in non-frozen builds.""" + """Auto-start toggle persists config and delegates to sync_startup.""" config = _make_config() window = _make_window(config) new_config = _make_config(auto_start=True) with ( patch.object(window._store, 'update', return_value=new_config), - patch('synodic_client.application.screen.settings.register_startup') as mock_register, - patch('synodic_client.application.screen.settings.remove_startup') as mock_remove, + patch('synodic_client.application.screen.settings.sync_startup') as mock_sync, patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False), - patch('synodic_client.application.screen.settings.getattr', return_value=False), ): window._auto_start_check.setChecked(True) - mock_register.assert_not_called() - mock_remove.assert_not_called() + # sync_startup is always called — it handles the frozen guard internally + mock_sync.assert_called_once() # --------------------------------------------------------------------------- diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index fcef507..33c92d3 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -28,26 +28,6 @@ # --------------------------------------------------------------------------- -class ModelSpy: - """Records signal emissions from an :class:`UpdateModel`.""" - - def __init__(self, model: UpdateModel) -> None: - self.status: list[tuple[str, str]] = [] - self.check_button_enabled: list[bool] = [] - self.restart_visible: list[bool] = [] - self.last_checked: list[str] = [] - - model.status_text_changed.connect(lambda t, s: self.status.append((t, s))) - model.check_button_enabled_changed.connect(self.check_button_enabled.append) - model.restart_visible_changed.connect(self.restart_visible.append) - model.last_checked_changed.connect(self.last_checked.append) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - class ModelSpy: """Records signal emissions from an :class:`UpdateModel`.""" @@ -382,6 +362,22 @@ def test_apply_update_noop_without_updater() -> None: client.apply_update_on_exit.assert_not_called() app.quit.assert_not_called() + @staticmethod + def test_apply_update_refreshes_startup_registry_when_frozen() -> None: + """_apply_update should call sync_startup before quitting.""" + ctrl, app, client, banner, model = _make_controller() + + with ( + patch('synodic_client.application.update_controller.sync_startup') as mock_sync, + patch('synodic_client.application.update_controller.sys') as mock_sys, + ): + mock_sys.executable = r'C:\app\synodic.exe' + ctrl._apply_update() + + mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=True) + client.apply_update_on_exit.assert_called_once() + app.quit.assert_called_once() + # --------------------------------------------------------------------------- # Settings changed → immediate check diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py index 0cae519..1fc53fe 100644 --- a/tests/unit/test_init.py +++ b/tests/unit/test_init.py @@ -26,8 +26,7 @@ def test_calls_seed_and_register_protocol() -> None: 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'), + patch(f'{_MODULE}.sync_startup'), patch(f'{_MODULE}.getattr', return_value=True), ): mock_resolve.return_value = MagicMock(auto_start=True) @@ -38,38 +37,34 @@ def test_calls_seed_and_register_protocol() -> None: mock_resolve.assert_called_once() @staticmethod - def test_registers_startup_when_auto_start_true() -> None: - """register_startup is called when auto_start is True and frozen.""" + def test_delegates_to_sync_startup_with_auto_start() -> None: + """sync_startup is called with the resolved auto_start preference.""" 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, + patch(f'{_MODULE}.sync_startup') as mock_sync, patch(f'{_MODULE}.getattr', return_value=True), ): 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() + mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=True) @staticmethod - def test_removes_startup_when_auto_start_false() -> None: - """remove_startup is called when auto_start is False and frozen.""" + def test_delegates_to_sync_startup_when_auto_start_false() -> None: + """sync_startup receives auto_start=False when config says so.""" 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, + patch(f'{_MODULE}.sync_startup') as mock_sync, patch(f'{_MODULE}.getattr', return_value=True), ): 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() + mock_sync.assert_called_once_with(r'C:\app\synodic.exe', auto_start=False) @staticmethod def test_defaults_exe_path_to_sys_executable() -> None: @@ -78,8 +73,7 @@ def test_defaults_exe_path_to_sys_executable() -> None: 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}.sync_startup') as mock_sync, patch(f'{_MODULE}.sys') as mock_sys, patch(f'{_MODULE}.getattr', return_value=True), ): @@ -88,25 +82,24 @@ def test_defaults_exe_path_to_sys_executable() -> None: 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') + mock_sync.assert_called_once_with(r'C:\Python\python.exe', auto_start=True) @staticmethod - def test_skips_registry_when_not_frozen() -> None: - """Protocol and startup registration are skipped in non-frozen builds.""" + def test_skips_protocol_when_not_frozen() -> None: + """Protocol registration is skipped in non-frozen builds.""" 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') as mock_remove, + patch(f'{_MODULE}.sync_startup') as mock_sync, patch(f'{_MODULE}.getattr', return_value=False), ): mock_resolve.return_value = MagicMock(auto_start=True) run_startup_preamble(r'C:\Python\python.exe') mock_proto.assert_not_called() - mock_register.assert_not_called() - mock_remove.assert_not_called() + # sync_startup is still called — it handles the frozen guard internally + mock_sync.assert_called_once() @staticmethod def test_idempotent_on_second_call() -> None: @@ -115,8 +108,7 @@ def test_idempotent_on_second_call() -> None: 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'), + patch(f'{_MODULE}.sync_startup'), ): mock_resolve.return_value = MagicMock(auto_start=True) run_startup_preamble(r'C:\app\synodic.exe') diff --git a/tests/unit/windows/test_startup.py b/tests/unit/windows/test_startup.py index 26a6dd2..19d0643 100644 --- a/tests/unit/windows/test_startup.py +++ b/tests/unit/windows/test_startup.py @@ -3,14 +3,18 @@ import winreg from unittest.mock import MagicMock, patch +import pytest + from synodic_client.startup import ( APPROVED_ENABLED, RUN_KEY_PATH, STARTUP_APPROVED_KEY_PATH, STARTUP_VALUE_NAME, + get_registered_startup_path, is_startup_registered, register_startup, remove_startup, + sync_startup, ) @@ -238,3 +242,124 @@ def test_returns_false_when_missing() -> None: patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError), ): assert is_startup_registered() is False + + +class TestGetRegisteredStartupPath: + """Tests for get_registered_startup_path.""" + + @staticmethod + def test_returns_unquoted_path() -> None: + """Verify the returned path has surrounding quotes stripped.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object( + winreg, + 'QueryValueEx', + return_value=(r'"C:\Program Files\Synodic\synodic.exe"', winreg.REG_SZ), + ), + ): + assert get_registered_startup_path() == r'C:\Program Files\Synodic\synodic.exe' + + @staticmethod + def test_returns_none_when_missing() -> None: + """Verify None when the registry value does not exist.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError), + ): + assert get_registered_startup_path() is None + + @staticmethod + def test_returns_none_on_os_error() -> None: + """Verify None when an OSError prevents reading the registry.""" + mock_key = MagicMock() + mock_key.__enter__ = MagicMock(return_value=mock_key) + mock_key.__exit__ = MagicMock(return_value=False) + + with ( + patch.object(winreg, 'OpenKey', return_value=mock_key), + patch.object(winreg, 'QueryValueEx', side_effect=OSError('access denied')), + ): + assert get_registered_startup_path() is None + + +_SYNC_MODULE = 'synodic_client.startup' + + +class TestSyncStartup: + """Tests for sync_startup.""" + + @staticmethod + def test_registers_when_auto_start_true() -> None: + """sync_startup calls register_startup when auto_start is True.""" + with ( + patch(f'{_SYNC_MODULE}.getattr', return_value=True), + patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=None), + patch(f'{_SYNC_MODULE}.register_startup') as mock_reg, + patch(f'{_SYNC_MODULE}.remove_startup') as mock_rem, + ): + sync_startup(r'C:\app\synodic.exe', auto_start=True) + + mock_reg.assert_called_once_with(r'C:\app\synodic.exe') + mock_rem.assert_not_called() + + @staticmethod + def test_removes_when_auto_start_false() -> None: + """sync_startup calls remove_startup when auto_start is False.""" + with ( + patch(f'{_SYNC_MODULE}.getattr', return_value=True), + patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=None), + patch(f'{_SYNC_MODULE}.register_startup') as mock_reg, + patch(f'{_SYNC_MODULE}.remove_startup') as mock_rem, + ): + sync_startup(r'C:\app\synodic.exe', auto_start=False) + + mock_rem.assert_called_once() + mock_reg.assert_not_called() + + @staticmethod + def test_noop_when_not_frozen() -> None: + """sync_startup is a no-op when sys.frozen is falsy.""" + with ( + patch(f'{_SYNC_MODULE}.getattr', return_value=False), + patch(f'{_SYNC_MODULE}.register_startup') as mock_reg, + patch(f'{_SYNC_MODULE}.remove_startup') as mock_rem, + ): + sync_startup(r'C:\app\synodic.exe', auto_start=True) + + mock_reg.assert_not_called() + mock_rem.assert_not_called() + + @staticmethod + def test_logs_warning_on_path_mismatch(caplog: pytest.LogCaptureFixture) -> None: + """A warning is logged when the registered path differs from exe_path.""" + with ( + patch(f'{_SYNC_MODULE}.getattr', return_value=True), + patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=r'C:\old\synodic.exe'), + patch(f'{_SYNC_MODULE}.register_startup'), + patch(f'{_SYNC_MODULE}.remove_startup'), + ): + sync_startup(r'C:\new\synodic.exe', auto_start=True) + + assert 'mismatch' in caplog.text.lower() + + @staticmethod + def test_no_warning_when_paths_match(caplog: pytest.LogCaptureFixture) -> None: + """No warning when the registered path matches exe_path.""" + with ( + patch(f'{_SYNC_MODULE}.getattr', return_value=True), + patch(f'{_SYNC_MODULE}.get_registered_startup_path', return_value=r'C:\app\synodic.exe'), + patch(f'{_SYNC_MODULE}.register_startup'), + patch(f'{_SYNC_MODULE}.remove_startup'), + ): + sync_startup(r'C:\app\synodic.exe', auto_start=True) + + assert 'mismatch' not in caplog.text.lower()