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
4 changes: 4 additions & 0 deletions .github/workflows/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ on:
permissions:
contents: write

concurrency:
group: release-build
cancel-in-progress: true

jobs:
get-version:
if: github.repository == 'synodic/synodic-client' && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success')
Expand Down
39 changes: 39 additions & 0 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Bootstrap entry point for PyInstaller builds.

Runs the lightweight startup preamble — logging, Velopack hooks, and
protocol registration — **before** importing heavy modules (PySide6,
porringer). Velopack's install/uninstall/update hooks have strict
timeouts (15–30 s) and must complete before the process is killed.

Import order matters:
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
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.updater import initialize_velopack

_PROTOCOL_SCHEME = 'synodic'

# Parse --dev flag early so logging uses the right filename.
_dev_mode = '--dev' in sys.argv[1:]
set_dev_mode(_dev_mode)

configure_logging()
initialize_velopack()

if not _dev_mode:
register_protocol(sys.executable)

# Heavy imports happen here — PySide6, porringer, etc.
from synodic_client.application.qt import application # noqa: E402

_uri = next((a for a in sys.argv[1:] if a.lower().startswith(f'{_PROTOCOL_SCHEME}://')), None)
application(uri=_uri, dev_mode=_dev_mode)
16 changes: 12 additions & 4 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

from porringer.api import API
from porringer.schema import SetupParameters, SyncStrategy
from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtGui import QAction
from PySide6.QtCore import QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QDesktopServices
from PySide6.QtWidgets import (
QApplication,
QDialog,
Expand All @@ -29,7 +29,7 @@
from synodic_client.application.theme import UPDATE_SOURCE_DIALOG_MIN_WIDTH
from synodic_client.client import Client
from synodic_client.config import GlobalConfiguration
from synodic_client.logging import open_log
from synodic_client.logging import log_path
from synodic_client.resolution import resolve_config, resolve_enabled_plugins, resolve_update_config, update_and_resolve
from synodic_client.updater import GITHUB_REPO_URL, UpdateChannel, UpdateInfo

Expand Down Expand Up @@ -281,7 +281,7 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None:
self.settings_menu.addSeparator()

self.open_log_action = QAction('Open Log...', self.settings_menu)
self.open_log_action.triggered.connect(open_log)
self.open_log_action.triggered.connect(self._open_log)
self.settings_menu.addAction(self.open_log_action)

self.menu.addSeparator()
Expand All @@ -294,6 +294,14 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None:

# -- Config helpers --

@staticmethod
def _open_log() -> None:
"""Open the log file in the system's default editor."""
path = log_path()
if not path.exists():
path.touch()
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))

def _resolve_config(self) -> GlobalConfiguration:
"""Return the injected config or resolve from disk."""
if self._config is not None:
Expand Down
3 changes: 2 additions & 1 deletion synodic_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import typer

from synodic_client import __version__
from synodic_client.application.qt import application

app = typer.Typer(
name='synodic-c',
Expand Down Expand Up @@ -38,4 +37,6 @@ def main(
] = False,
) -> None:
"""Launch the Synodic Client GUI application."""
from synodic_client.application.qt import application # noqa: PLC0415

application(uri=uri, dev_mode=dev)
28 changes: 9 additions & 19 deletions synodic_client/logging.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
"""Centralised logging configuration for the Synodic Client.

Provides a rotating file handler with eager flushing and a helper to open
the current log file in the system's default editor.
Provides a rotating file handler with eager flushing.
"""

import logging
import tempfile
from logging.handlers import RotatingFileHandler
from pathlib import Path

from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices

from synodic_client.config import is_dev_mode

_LOG_FILENAME = 'synodic.log'
Expand Down Expand Up @@ -52,7 +48,15 @@ def configure_logging() -> None:
Attaches a :class:`EagerRotatingFileHandler` to the ``synodic_client``
and ``porringer`` loggers and configures :func:`logging.basicConfig`
for ``INFO`` level output on *stderr*.

Safe to call more than once — subsequent calls are no-ops.
"""
app_logger = logging.getLogger('synodic_client')

# Guard: skip if already configured (e.g. by bootstrap.py)
if any(isinstance(h, EagerRotatingFileHandler) for h in app_logger.handlers):
return

logging.basicConfig(level=logging.INFO)

handler = EagerRotatingFileHandler(
Expand All @@ -63,22 +67,8 @@ def configure_logging() -> None:
)
handler.setFormatter(logging.Formatter(_FORMAT))

app_logger = logging.getLogger('synodic_client')
app_logger.addHandler(handler)

porringer_logger = logging.getLogger('porringer')
porringer_logger.addHandler(handler)
porringer_logger.setLevel(logging.INFO)


def open_log() -> None:
"""Open the log file in the system's default editor.

Creates an empty file if one does not yet exist so that the OS always
has something to open.
"""
path = log_path()
if not path.exists():
path.touch()

QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
6 changes: 3 additions & 3 deletions synodic_client/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
}


def _platform_suffix() -> str:
def platform_suffix() -> str:
"""Return the Velopack channel suffix for the current platform."""
try:
return _PLATFORM_SUFFIXES[sys.platform]
Expand Down Expand Up @@ -104,8 +104,8 @@ def channel_name(self) -> str:
so each OS has its own release manifest and nupkg files.
"""
base = 'dev' if self.channel == UpdateChannel.DEVELOPMENT else 'stable'
platform_suffix = _platform_suffix()
return f'{base}-{platform_suffix}'
suffix = platform_suffix()
return f'{base}-{suffix}'


class Updater:
Expand Down
20 changes: 10 additions & 10 deletions tests/unit/qt/test_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
from pathlib import Path
from unittest.mock import patch

from synodic_client.application.screen.tray import TrayScreen
from synodic_client.config import set_dev_mode
from synodic_client.logging import (
EagerRotatingFileHandler,
configure_logging,
log_path,
open_log,
)


Expand Down Expand Up @@ -106,31 +106,31 @@ def test_writes_to_file(tmp_path: Path) -> None:


class TestOpenLog:
"""Tests for open_log()."""
"""Tests for TrayScreen._open_log()."""

@staticmethod
def test_creates_file_if_missing(tmp_path: Path) -> None:
"""open_log() should create the log file when it does not exist."""
"""_open_log() should create the log file when it does not exist."""
log_file = tmp_path / 'synodic.log'
assert not log_file.exists()

with (
patch('synodic_client.logging.log_path', return_value=log_file),
patch('synodic_client.logging.QDesktopServices') as mock_ds,
patch('synodic_client.application.screen.tray.log_path', return_value=log_file),
patch('synodic_client.application.screen.tray.QDesktopServices') as mock_ds,
):
open_log()
TrayScreen._open_log()
assert log_file.exists()
mock_ds.openUrl.assert_called_once()

@staticmethod
def test_opens_existing_file(tmp_path: Path) -> None:
"""open_log() should open an existing log file without error."""
"""_open_log() should open an existing log file without error."""
log_file = tmp_path / 'synodic.log'
log_file.write_text('existing content', encoding='utf-8')

with (
patch('synodic_client.logging.log_path', return_value=log_file),
patch('synodic_client.logging.QDesktopServices') as mock_ds,
patch('synodic_client.application.screen.tray.log_path', return_value=log_file),
patch('synodic_client.application.screen.tray.QDesktopServices') as mock_ds,
):
open_log()
TrayScreen._open_log()
mock_ds.openUrl.assert_called_once()
6 changes: 3 additions & 3 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_help() -> None:
@staticmethod
def test_launches_application_without_uri() -> None:
"""Verify invoking with no args calls application(uri=None, dev_mode=False)."""
with patch('synodic_client.cli.application') as mock_app:
with patch('synodic_client.application.qt.application') as mock_app:
result = runner.invoke(app, [])
assert result.exit_code == 0
mock_app.assert_called_once_with(uri=None, dev_mode=False)
Expand All @@ -42,15 +42,15 @@ def test_launches_application_without_uri() -> None:
def test_launches_application_with_uri() -> None:
"""Verify invoking with a URI passes it to application()."""
test_uri = 'synodic://install?manifest=https://example.com/foo.json'
with patch('synodic_client.cli.application') as mock_app:
with patch('synodic_client.application.qt.application') as mock_app:
result = runner.invoke(app, [test_uri])
assert result.exit_code == 0
mock_app.assert_called_once_with(uri=test_uri, dev_mode=False)

@staticmethod
def test_launches_application_with_dev_flag() -> None:
"""Verify --dev flag sets dev_mode=True."""
with patch('synodic_client.cli.application') as mock_app:
with patch('synodic_client.application.qt.application') as mock_app:
result = runner.invoke(app, ['--dev'])
assert result.exit_code == 0
mock_app.assert_called_once_with(uri=None, dev_mode=True)
6 changes: 3 additions & 3 deletions tests/unit/test_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
UpdateInfo,
Updater,
UpdateState,
_platform_suffix,
initialize_velopack,
platform_suffix,
)


Expand Down Expand Up @@ -127,13 +127,13 @@ def test_custom_values() -> None:
def test_channel_name_stable() -> None:
"""Verify STABLE channel returns platform-specific 'stable' name."""
config = UpdateConfig(channel=UpdateChannel.STABLE)
assert config.channel_name == f'stable-{_platform_suffix()}'
assert config.channel_name == f'stable-{platform_suffix()}'

@staticmethod
def test_channel_name_development() -> None:
"""Verify DEVELOPMENT channel returns platform-specific 'dev' name."""
config = UpdateConfig(channel=UpdateChannel.DEVELOPMENT)
assert config.channel_name == f'dev-{_platform_suffix()}'
assert config.channel_name == f'dev-{platform_suffix()}'


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion tool/pyinstaller/synodic.spec
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ hiddenimports += [
]

a = Analysis(
[str(REPO_ROOT / 'synodic_client' / 'application' / 'qt.py')],
[str(REPO_ROOT / 'synodic_client' / 'application' / 'bootstrap.py')],
pathex=[],
binaries=[],
datas=datas,
Expand Down