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
16 changes: 3 additions & 13 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@
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
"""

import sys

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'
Expand All @@ -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
Expand Down
61 changes: 61 additions & 0 deletions synodic_client/application/init.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 4 additions & 16 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,15 +24,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


Expand Down Expand Up @@ -139,20 +137,10 @@ 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()

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)
Expand Down
77 changes: 71 additions & 6 deletions synodic_client/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
"""
Expand All @@ -17,6 +29,15 @@

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
"""Enabled payload for the ``StartupApproved\\Run`` registry value."""

_APPROVED_DISABLED_BYTE: int = 0x03


if sys.platform == 'win32':
import winreg
Expand All @@ -25,8 +46,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.
Expand All @@ -38,10 +63,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:
Expand All @@ -52,22 +87,52 @@ 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.
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``.

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:
Expand Down
10 changes: 9 additions & 1 deletion synodic_client/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,20 +433,28 @@ 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.

This should be called as early as possible in the application lifecycle,
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()
Expand Down
Loading