From 9b6b5efe7793d26762f32eb1164fb66308ab931b Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 11 Mar 2026 09:45:04 -0700 Subject: [PATCH 1/2] Simplify Plugin Collection --- pdm.lock | 8 +++--- pyproject.toml | 2 +- tests/unit/qt/test_update_controller.py | 2 +- tool/pyinstaller/synodic.spec | 34 +++---------------------- 4 files changed, 10 insertions(+), 36 deletions(-) diff --git a/pdm.lock b/pdm.lock index df4dd57..27f8ee7 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:52b09137c7599dc609e087bb58f7ced9bd9940ac53f77e0b6401eeb30d3f6bb3" +content_hash = "sha256:503d9c28c08a152250042e09282d3a4e727af0d4aac551c4e0fcb92dc06459bf" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev79" +version = "0.2.1.dev80" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev79-py3-none-any.whl", hash = "sha256:fa010a1887f376827d154794dfa1ec8552263fb596e987bea8fa2e0e3920e478"}, - {file = "porringer-0.2.1.dev79.tar.gz", hash = "sha256:328de37f930e0042341378037c1b3bb9e36479485748f01569d8fac0936883df"}, + {file = "porringer-0.2.1.dev80-py3-none-any.whl", hash = "sha256:af53ed2b9f40900898b226694d5f49c65d8eda5487132781fafa9dca7504e4b7"}, + {file = "porringer-0.2.1.dev80.tar.gz", hash = "sha256:aa40413b26d14391cd8c565dc1e2168a73b5f5e15fce16372d99998714ebf903"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 1eb06d0..8c31172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires-python = ">=3.14, <3.15" dependencies = [ "pyside6>=6.10.2", "packaging>=26.0", - "porringer>=0.2.1.dev79", + "porringer>=0.2.1.dev80", "qasync>=0.28.0", "velopack>=0.0.1444.dev49733", "typer>=0.24.1", diff --git a/tests/unit/qt/test_update_controller.py b/tests/unit/qt/test_update_controller.py index f3a73e4..fcef507 100644 --- a/tests/unit/qt/test_update_controller.py +++ b/tests/unit/qt/test_update_controller.py @@ -23,7 +23,6 @@ UpdateInfo, ) - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -43,6 +42,7 @@ def __init__(self, model: UpdateModel) -> None: model.restart_visible_changed.connect(self.restart_visible.append) model.last_checked_changed.connect(self.last_checked.append) + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tool/pyinstaller/synodic.spec b/tool/pyinstaller/synodic.spec index a6b30b1..e132b41 100644 --- a/tool/pyinstaller/synodic.spec +++ b/tool/pyinstaller/synodic.spec @@ -1,46 +1,20 @@ # -*- mode: python ; coding: utf-8 -*- from pathlib import Path -from PyInstaller.utils.hooks import copy_metadata +from PyInstaller.utils.hooks import collect_submodules, copy_metadata SPEC_DIR = Path(SPECPATH) REPO_ROOT = SPEC_DIR.parent.parent # Collect porringer and its plugins with metadata datas = [(str(REPO_ROOT / 'data'), 'data')] -hiddenimports = [] # Add porringer metadata so entry points work datas += copy_metadata('porringer') -# Porringer bundled plugins (discovered via entry points at runtime). -# Keep in sync with porringer's pyproject.toml [project.entry-points.*] groups. -hiddenimports += [ - # porringer.environment - 'porringer.plugin.apt.plugin', - 'porringer.plugin.brew.plugin', - 'porringer.plugin.bun.plugin', - 'porringer.plugin.deno.plugin', - 'porringer.plugin.npm.plugin', - 'porringer.plugin.pim.plugin', - 'porringer.plugin.pip.plugin', - 'porringer.plugin.pipx.plugin', - 'porringer.plugin.pnpm.plugin', - 'porringer.plugin.pyenv.plugin', - 'porringer.plugin.uv.plugin', - 'porringer.plugin.winget.plugin', - # porringer.project_environment - 'porringer.plugin.bun_project.plugin', - 'porringer.plugin.deno_project.plugin', - 'porringer.plugin.npm_project.plugin', - 'porringer.plugin.pdm.plugin', - 'porringer.plugin.pnpm_project.plugin', - 'porringer.plugin.poetry.plugin', - 'porringer.plugin.uv_project.plugin', - 'porringer.plugin.yarn_project.plugin', - # porringer.scm - 'porringer.plugin.git.plugin', -] +# Auto-discover all porringer plugin modules so new upstream plugins +# are bundled without manual spec updates. +hiddenimports = collect_submodules('porringer.plugin') a = Analysis( [str(REPO_ROOT / 'synodic_client' / 'application' / 'bootstrap.py')], From 5f9ad35569aabb32204d594a656fa93a46625a21 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 11 Mar 2026 14:16:06 -0700 Subject: [PATCH 2/2] Auto-Start Cleanup --- synodic_client/application/bootstrap.py | 28 +++- synodic_client/application/init.py | 8 +- synodic_client/application/screen/settings.py | 8 +- synodic_client/application/screen/tray.py | 39 +++++- .../application/update_controller.py | 10 ++ synodic_client/startup.py | 57 ++++++++ tests/unit/qt/test_settings.py | 24 ++-- tests/unit/qt/test_update_controller.py | 36 +++-- tests/unit/test_init.py | 42 +++--- tests/unit/windows/test_startup.py | 125 ++++++++++++++++++ 10 files changed, 299 insertions(+), 78 deletions(-) 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()