Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:]
Expand All @@ -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:
Expand Down
8 changes: 2 additions & 6 deletions synodic_client/application/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
8 changes: 2 additions & 6 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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)
Expand Down
39 changes: 38 additions & 1 deletion synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions synodic_client/application/update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down
57 changes: 57 additions & 0 deletions synodic_client/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 '"<path>"'; 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.

Expand Down Expand Up @@ -147,10 +165,49 @@ 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).

Returns:
``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()
24 changes: 10 additions & 14 deletions tests/unit/qt/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()


# ---------------------------------------------------------------------------
Expand Down
36 changes: 16 additions & 20 deletions tests/unit/qt/test_update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."""

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading