From 7f13bb3b80ac1b3e2ed5f9e1c9971f8bfc1294e6 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 09:15:51 -0800 Subject: [PATCH 1/3] Update release-build.yml --- .github/workflows/release-build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 660e626..68a4ba3 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -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') From 4491e763c61652853ce929fe6d6c557a1230625d Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 09:43:05 -0800 Subject: [PATCH 2/3] Isolate Velopack Init --- synodic_client/application/bootstrap.py | 39 +++++++++++++++++++++++ synodic_client/application/screen/tray.py | 16 +++++++--- synodic_client/cli.py | 3 +- synodic_client/logging.py | 28 ++++++---------- synodic_client/updater.py | 6 ++-- tests/unit/qt/test_logging.py | 20 ++++++------ tests/unit/test_cli.py | 6 ++-- tests/unit/test_updater.py | 6 ++-- tool/pyinstaller/synodic.spec | 2 +- 9 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 synodic_client/application/bootstrap.py diff --git a/synodic_client/application/bootstrap.py b/synodic_client/application/bootstrap.py new file mode 100644 index 0000000..631cdf5 --- /dev/null +++ b/synodic_client/application/bootstrap.py @@ -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) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index 291baaa..ac968bd 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -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, @@ -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 @@ -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() @@ -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: diff --git a/synodic_client/cli.py b/synodic_client/cli.py index 0a0a55e..ca7494f 100644 --- a/synodic_client/cli.py +++ b/synodic_client/cli.py @@ -5,7 +5,6 @@ import typer from synodic_client import __version__ -from synodic_client.application.qt import application app = typer.Typer( name='synodic-c', @@ -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) diff --git a/synodic_client/logging.py b/synodic_client/logging.py index 040cb6b..70d3e26 100644 --- a/synodic_client/logging.py +++ b/synodic_client/logging.py @@ -1,7 +1,6 @@ """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 @@ -9,9 +8,6 @@ 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' @@ -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( @@ -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))) diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 1b5438e..a2e1f6c 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -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] @@ -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: diff --git a/tests/unit/qt/test_logging.py b/tests/unit/qt/test_logging.py index d599a3c..ebcd159 100644 --- a/tests/unit/qt/test_logging.py +++ b/tests/unit/qt/test_logging.py @@ -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, ) @@ -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() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index cf89a91..ca25821 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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) @@ -42,7 +42,7 @@ 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) @@ -50,7 +50,7 @@ def test_launches_application_with_uri() -> None: @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) diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 5789113..d50555e 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -14,8 +14,8 @@ UpdateInfo, Updater, UpdateState, - _platform_suffix, initialize_velopack, + platform_suffix, ) @@ -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 diff --git a/tool/pyinstaller/synodic.spec b/tool/pyinstaller/synodic.spec index 29a89dc..94d5dcc 100644 --- a/tool/pyinstaller/synodic.spec +++ b/tool/pyinstaller/synodic.spec @@ -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, From 2732479735fdc0bdb1c13811d6191d1eec423e90 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Mon, 16 Feb 2026 10:33:18 -0800 Subject: [PATCH 3/3] Remove Callback For Now --- synodic_client/updater.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/synodic_client/updater.py b/synodic_client/updater.py index a2e1f6c..5721c50 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -393,13 +393,16 @@ def initialize_velopack() -> None: before any UI is shown. Velopack may need to perform cleanup or apply pending updates. - On Windows, the uninstall hook removes the ``synodic://`` URI protocol. Protocol registration happens on every app launch (see ``qt.application``). + + .. note:: + + The SDK's callback hooks only accept ``PyCFunction`` — add an + uninstall hook here when that is fixed upstream. """ logger.info('Initializing Velopack (exe=%s)', sys.executable) try: app = velopack.App() - app.on_before_uninstall_fast_callback(_on_before_uninstall) app.run() logger.info('Velopack initialized successfully') except Exception as e: