From 1349121910cbc9418dc57efab86794c78d55ff70 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 24 Feb 2026 11:36:50 -0800 Subject: [PATCH 1/2] Updated Stable/Dev Channel URLs --- synodic_client/application/qt.py | 3 +-- synodic_client/resolution.py | 6 ++++- synodic_client/updater.py | 42 ++++++++++++++++++++++++++++++-- tests/unit/test_resolution.py | 21 ++++++++++------ tests/unit/test_updater.py | 36 +++++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/synodic_client/application/qt.py b/synodic_client/application/qt.py index 523f563..4f08c57 100644 --- a/synodic_client/application/qt.py +++ b/synodic_client/application/qt.py @@ -47,8 +47,7 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura cached_dirs = porringer.cache.list_directories() logger.info( - 'Synodic Client v%s started (channel: %s, source: %s, ' - 'config_fields_set: %s, cached_projects: %d)', + 'Synodic Client v%s started (channel: %s, source: %s, config_fields_set: %s, cached_projects: %d)', client.version, update_config.channel.name, update_config.repo_url, diff --git a/synodic_client/resolution.py b/synodic_client/resolution.py index 35265c3..215d25f 100644 --- a/synodic_client/resolution.py +++ b/synodic_client/resolution.py @@ -21,6 +21,7 @@ GITHUB_REPO_URL, UpdateChannel, UpdateConfig, + github_release_asset_url, ) logger = logging.getLogger(__name__) @@ -84,7 +85,10 @@ def resolve_update_config(config: GlobalConfiguration) -> UpdateConfig: else: channel = UpdateChannel.DEVELOPMENT if is_dev else UpdateChannel.STABLE - repo_url = config.update_source or GITHUB_REPO_URL + repo_url = github_release_asset_url( + config.update_source or GITHUB_REPO_URL, + channel, + ) interval = config.auto_update_interval_minutes if interval is None: diff --git a/synodic_client/updater.py b/synodic_client/updater.py index 549a130..2332fdc 100644 --- a/synodic_client/updater.py +++ b/synodic_client/updater.py @@ -22,10 +22,48 @@ logger = logging.getLogger(__name__) -# GitHub repository for Velopack updates -# Velopack automatically discovers releases from GitHub releases +# GitHub repository base URL. Transformed into a release-asset URL +# by :func:`github_release_asset_url` at resolution time so that +# Velopack's ``HttpSource`` can fetch ``releases.{channel}.json`` +# from the correct GitHub Releases download path. GITHUB_REPO_URL = 'https://github.com/synodic/synodic-client' +# Fixed tag used for rolling development releases on GitHub. +_DEV_RELEASE_TAG = 'dev' + + +def github_release_asset_url(repo_url: str, channel: UpdateChannel) -> str: + """Convert a GitHub repository URL into a release-asset download URL. + + Velopack's runtime SDK uses a plain ``HttpSource`` that requests + ``{base_url}/releases.{channel}.json``. GitHub serves release assets + at ``{repo}/releases/download/{tag}/`` (for a specific tag) or + ``{repo}/releases/latest/download/`` (auto-resolves to the newest + non-prerelease release). + + * **Development** channel → ``/releases/download/dev/`` + * **Stable** channel → ``/releases/latest/download/`` + + Non-GitHub URLs (local paths, custom HTTP servers) are returned + unchanged. + + Args: + repo_url: A GitHub repository URL or custom update source. + channel: The resolved update channel. + + Returns: + A URL (or path) suitable for Velopack's ``UpdateManager``. + """ + normalized = repo_url.rstrip('/') + # Only transform URLs that look like a GitHub repository. + if not normalized.startswith(('https://github.com/', 'http://github.com/')): + return repo_url + + if channel == UpdateChannel.DEVELOPMENT: + return f'{normalized}/releases/download/{_DEV_RELEASE_TAG}' + return f'{normalized}/releases/latest/download' + + # Map sys.platform values to Velopack channel suffixes _PLATFORM_SUFFIXES: dict[str, str] = { 'win32': 'win', diff --git a/tests/unit/test_resolution.py b/tests/unit/test_resolution.py index d5ff46c..ad306ad 100644 --- a/tests/unit/test_resolution.py +++ b/tests/unit/test_resolution.py @@ -333,18 +333,25 @@ def test_default_channel_unfrozen() -> None: assert result.channel == UpdateChannel.DEVELOPMENT @staticmethod - def test_custom_source() -> None: - """Verify custom update source is used.""" + def test_custom_source_non_github() -> None: + """Verify non-GitHub custom source passes through unchanged.""" config = GlobalConfiguration(update_source='https://custom.example.com') result = resolve_update_config(config) assert result.repo_url == 'https://custom.example.com' @staticmethod - def test_default_source() -> None: - """Verify default GITHUB_REPO_URL is used when source is None.""" - config = GlobalConfiguration() + def test_default_source_dev() -> None: + """Verify default dev source uses GitHub download path with dev tag.""" + config = GlobalConfiguration(update_channel='dev') + result = resolve_update_config(config) + assert result.repo_url == f'{GITHUB_REPO_URL}/releases/download/dev' + + @staticmethod + def test_default_source_stable() -> None: + """Verify default stable source uses GitHub latest download path.""" + config = GlobalConfiguration(update_channel='stable') result = resolve_update_config(config) - assert result.repo_url == GITHUB_REPO_URL + assert result.repo_url == f'{GITHUB_REPO_URL}/releases/latest/download' @staticmethod def test_default_auto_update_interval() -> None: @@ -397,7 +404,7 @@ def test_saves_and_resolves(tmp_path: Path) -> None: result = update_and_resolve(config) assert result.channel == UpdateChannel.DEVELOPMENT - assert result.repo_url == '/my/source' + assert result.repo_url == '/my/source' # non-GitHub path unchanged # Verify file was saved (sparse — only user-set fields) saved = json.loads((tmp_path / 'config.json').read_text(encoding='utf-8')) diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 90c1af5..3a5814d 100644 --- a/tests/unit/test_updater.py +++ b/tests/unit/test_updater.py @@ -12,6 +12,7 @@ UpdateInfo, Updater, UpdateState, + github_release_asset_url, initialize_velopack, platform_suffix, ) @@ -33,6 +34,41 @@ def test_channel_name_development() -> None: assert config.channel_name == f'dev-{platform_suffix()}' +class TestGithubReleaseAssetUrl: + """Tests for github_release_asset_url helper.""" + + @staticmethod + def test_dev_channel() -> None: + """Verify dev channel produces /releases/download/dev URL.""" + url = github_release_asset_url(GITHUB_REPO_URL, UpdateChannel.DEVELOPMENT) + assert url == f'{GITHUB_REPO_URL}/releases/download/dev' + + @staticmethod + def test_stable_channel() -> None: + """Verify stable channel produces /releases/latest/download URL.""" + url = github_release_asset_url(GITHUB_REPO_URL, UpdateChannel.STABLE) + assert url == f'{GITHUB_REPO_URL}/releases/latest/download' + + @staticmethod + def test_non_github_url_unchanged() -> None: + """Verify non-GitHub URLs pass through unchanged.""" + custom = 'https://custom.example.com/updates' + assert github_release_asset_url(custom, UpdateChannel.DEVELOPMENT) == custom + assert github_release_asset_url(custom, UpdateChannel.STABLE) == custom + + @staticmethod + def test_local_path_unchanged() -> None: + """Verify local file paths pass through unchanged.""" + path = '/srv/releases' + assert github_release_asset_url(path, UpdateChannel.DEVELOPMENT) == path + + @staticmethod + def test_trailing_slash_stripped() -> None: + """Verify trailing slashes are stripped before appending path.""" + url = github_release_asset_url('https://github.com/owner/repo/', UpdateChannel.DEVELOPMENT) + assert url == 'https://github.com/owner/repo/releases/download/dev' + + @pytest.fixture def updater() -> Updater: """Create an Updater instance for testing.""" From 0064cba4486f791d7a927b5b6e4b36156df42c2d Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Tue, 24 Feb 2026 12:41:57 -0800 Subject: [PATCH 2/2] Settings Page --- pdm.lock | 8 +- pyproject.toml | 2 +- synodic_client/application/screen/screen.py | 11 + synodic_client/application/screen/settings.py | 289 ++++++++++++++++ synodic_client/application/screen/tray.py | 162 ++------- synodic_client/application/theme.py | 13 +- tests/unit/qt/test_logging.py | 16 +- tests/unit/qt/test_settings.py | 308 ++++++++++++++++++ 8 files changed, 666 insertions(+), 143 deletions(-) create mode 100644 synodic_client/application/screen/settings.py create mode 100644 tests/unit/qt/test_settings.py diff --git a/pdm.lock b/pdm.lock index 5113050..3b13d37 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "build", "dev", "lint", "test"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:d83e9bf82339b08c1aeabb99f878d82f4bf3606ee7f616bada987d44b6a38160" +content_hash = "sha256:eacda2145aab3b87b828c629b7c0ceee4970d71a8a8dcdada110c7df123507af" [[metadata.targets]] requires_python = ">=3.14,<3.15" @@ -336,7 +336,7 @@ files = [ [[package]] name = "porringer" -version = "0.2.1.dev49" +version = "0.2.1.dev50" requires_python = ">=3.14" summary = "" groups = ["default"] @@ -349,8 +349,8 @@ dependencies = [ "userpath>=1.9.2", ] files = [ - {file = "porringer-0.2.1.dev49-py3-none-any.whl", hash = "sha256:f162456b180b1d90a58d649bd7b38b07b7a3b7520b71f8b7009ddae533e10521"}, - {file = "porringer-0.2.1.dev49.tar.gz", hash = "sha256:731ba5b3bf4c9461636246a21de1e416d8e029392bc0740ea1656c7a7d91ece9"}, + {file = "porringer-0.2.1.dev50-py3-none-any.whl", hash = "sha256:ff3f4eb3aef60ddf4ba94eb15b962481e2c0b17c8419be37bd01e85bd1ab79a6"}, + {file = "porringer-0.2.1.dev50.tar.gz", hash = "sha256:2d8fbc1294e519c6ae9aa470b36183977ff762c62c110a91085c255a1798ebc0"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 894939c..76eacd2 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.dev49", + "porringer>=0.2.1.dev50", "qasync>=0.28.0", "velopack>=0.0.1442.dev64255", "typer>=0.24.1", diff --git a/synodic_client/application/screen/screen.py b/synodic_client/application/screen/screen.py index 2cf1680..29bd476 100644 --- a/synodic_client/application/screen/screen.py +++ b/synodic_client/application/screen/screen.py @@ -51,6 +51,7 @@ PLUGIN_SECTION_SPACING, PLUGIN_TOGGLE_STYLE, PLUGIN_UPDATE_STYLE, + SETTINGS_GEAR_STYLE, ) from synodic_client.config import GlobalConfiguration, save_config @@ -794,6 +795,9 @@ def _update_remove_btn(self) -> None: class MainWindow(QMainWindow): """Main window for the application.""" + settings_requested = Signal() + """Emitted when the user clicks the settings gear button.""" + _tabs: QTabWidget | None = None _plugins_view: PluginsView | None = None _projects_view: ProjectsView | None = None @@ -837,6 +841,13 @@ def show(self) -> None: self._plugins_view = PluginsView(self._porringer, self._config, self) self._tabs.addTab(self._plugins_view, 'Plugins') + gear_btn = QPushButton('\u2699') + gear_btn.setStyleSheet(SETTINGS_GEAR_STYLE) + gear_btn.setToolTip('Settings') + gear_btn.setFlat(True) + gear_btn.clicked.connect(self.settings_requested.emit) + self._tabs.setCornerWidget(gear_btn) + self.setCentralWidget(self._tabs) # Paint the window immediately, then refresh data asynchronously diff --git a/synodic_client/application/screen/settings.py b/synodic_client/application/screen/settings.py new file mode 100644 index 0000000..9c57187 --- /dev/null +++ b/synodic_client/application/screen/settings.py @@ -0,0 +1,289 @@ +"""Settings window for the Synodic Client application. + +Provides a single-page window with grouped sections for all application +settings. Quick-access items (Channel, Check for Updates) remain in the +tray menu; the full set is available here. +""" + +import logging +import sys +from collections.abc import Iterator +from contextlib import contextmanager + +from PySide6.QtCore import QUrl, Signal +from PySide6.QtGui import QDesktopServices +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QScrollArea, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from synodic_client.application.icon import app_icon +from synodic_client.application.screen.card import CardFrame +from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE +from synodic_client.config import GlobalConfiguration, save_config +from synodic_client.logging import log_path +from synodic_client.startup import is_startup_registered, register_startup, remove_startup +from synodic_client.updater import ( + DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, + DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, + GITHUB_REPO_URL, +) + +logger = logging.getLogger(__name__) + + +class SettingsWindow(QMainWindow): + """Application settings window with grouped card sections. + + All controls persist changes immediately via :func:`save_config` and + emit :attr:`settings_changed` so that the tray and updater can react. + """ + + settings_changed = Signal() + """Emitted whenever a setting is changed and persisted.""" + + def __init__( + self, + config: GlobalConfiguration, + parent: QWidget | None = None, + ) -> None: + """Initialise the settings window. + + Args: + config: The shared global configuration object. + parent: Optional parent widget. + """ + super().__init__(parent) + self._config = config + self.setWindowTitle('Synodic Settings') + self.setMinimumSize(*SETTINGS_WINDOW_MIN_SIZE) + self.setWindowIcon(app_icon()) + self._init_ui() + + # ------------------------------------------------------------------ + # UI construction + # ------------------------------------------------------------------ + + def _init_ui(self) -> None: + """Build the scrollable settings layout.""" + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + + container = QWidget() + layout = QVBoxLayout(container) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) + + layout.addWidget(self._build_updates_section()) + layout.addWidget(self._build_startup_section()) + layout.addWidget(self._build_advanced_section()) + layout.addStretch() + + scroll.setWidget(container) + self.setCentralWidget(scroll) + + def _build_updates_section(self) -> CardFrame: + """Construct the *Updates* settings card.""" + card = CardFrame('Updates') + content = card.content_layout + + # Channel + row = QHBoxLayout() + label = QLabel('Channel') + label.setMinimumWidth(160) + row.addWidget(label) + self._channel_combo = QComboBox() + self._channel_combo.addItems(['Stable', 'Development']) + self._channel_combo.currentIndexChanged.connect(self._on_channel_changed) + row.addWidget(self._channel_combo) + row.addStretch() + content.addLayout(row) + + # Update Source + row = QHBoxLayout() + label = QLabel('Update source') + label.setMinimumWidth(160) + row.addWidget(label) + self._source_edit = QLineEdit() + self._source_edit.setPlaceholderText(GITHUB_REPO_URL) + self._source_edit.editingFinished.connect(self._on_source_changed) + row.addWidget(self._source_edit, 1) + browse_btn = QPushButton('Browse\u2026') + browse_btn.clicked.connect(self._on_browse_source) + row.addWidget(browse_btn) + content.addLayout(row) + + # Auto-update interval + row = QHBoxLayout() + label = QLabel('App update interval (min)') + label.setMinimumWidth(160) + row.addWidget(label) + self._auto_update_spin = QSpinBox() + self._auto_update_spin.setRange(0, 1440) + self._auto_update_spin.setSpecialValueText('Disabled') + self._auto_update_spin.valueChanged.connect(self._on_auto_update_interval_changed) + row.addWidget(self._auto_update_spin) + row.addStretch() + content.addLayout(row) + + # Tool-update interval + row = QHBoxLayout() + label = QLabel('Tool update interval (min)') + label.setMinimumWidth(160) + row.addWidget(label) + self._tool_update_spin = QSpinBox() + self._tool_update_spin.setRange(0, 1440) + self._tool_update_spin.setSpecialValueText('Disabled') + self._tool_update_spin.valueChanged.connect(self._on_tool_update_interval_changed) + row.addWidget(self._tool_update_spin) + row.addStretch() + content.addLayout(row) + + # Detect updates during previews + self._detect_updates_check = QCheckBox('Detect updates during previews') + self._detect_updates_check.toggled.connect(self._on_detect_updates_changed) + content.addWidget(self._detect_updates_check) + + return card + + def _build_startup_section(self) -> CardFrame: + """Construct the *Startup* settings card.""" + card = CardFrame('Startup') + self._auto_start_check = QCheckBox('Start with Windows') + self._auto_start_check.toggled.connect(self._on_auto_start_changed) + card.content_layout.addWidget(self._auto_start_check) + return card + + def _build_advanced_section(self) -> CardFrame: + """Construct the *Advanced* settings card.""" + card = CardFrame('Advanced') + row = QHBoxLayout() + open_log_btn = QPushButton('Open Log\u2026') + open_log_btn.clicked.connect(self._open_log) + row.addWidget(open_log_btn) + row.addStretch() + card.content_layout.addLayout(row) + return card + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def sync_from_config(self) -> None: + """Synchronize all controls from the current configuration. + + Signals are blocked during the update to prevent feedback loops. + """ + config = self._config + + with self._block_signals(): + # Channel: index 0 = Stable, 1 = Development + is_dev = config.update_channel == 'dev' + self._channel_combo.setCurrentIndex(1 if is_dev else 0) + + # Update source + self._source_edit.setText(config.update_source or '') + + # Intervals + auto_interval = config.auto_update_interval_minutes + self._auto_update_spin.setValue( + auto_interval if auto_interval is not None else DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, + ) + tool_interval = config.tool_update_interval_minutes + self._tool_update_spin.setValue( + tool_interval if tool_interval is not None else DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES, + ) + + # Checkboxes + self._detect_updates_check.setChecked(config.detect_updates) + self._auto_start_check.setChecked(is_startup_registered()) + + def show(self) -> None: + """Sync controls from config, then show the window.""" + self.sync_from_config() + super().show() + self.raise_() + self.activateWindow() + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + + def _persist(self) -> None: + """Save config and notify listeners.""" + save_config(self._config) + self.settings_changed.emit() + + @contextmanager + def _block_signals(self) -> Iterator[None]: + """Temporarily block signals on all settings controls.""" + widgets = ( + self._channel_combo, + self._source_edit, + self._auto_update_spin, + self._tool_update_spin, + self._detect_updates_check, + self._auto_start_check, + ) + for w in widgets: + w.blockSignals(True) + try: + yield + finally: + for w in widgets: + w.blockSignals(False) + + def _on_channel_changed(self, index: int) -> None: + self._config.update_channel = 'dev' if index == 1 else 'stable' + self._persist() + + def _on_source_changed(self) -> None: + text = self._source_edit.text().strip() + self._config.update_source = text or None + self._persist() + + def _on_browse_source(self) -> None: + path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory') + if path: + self._source_edit.setText(path) + self._on_source_changed() + + def _on_auto_update_interval_changed(self, value: int) -> None: + self._config.auto_update_interval_minutes = value + self._persist() + + def _on_tool_update_interval_changed(self, value: int) -> None: + self._config.tool_update_interval_minutes = value + self._persist() + + def _on_detect_updates_changed(self, checked: bool) -> None: + self._config.detect_updates = checked + self._persist() + + def _on_auto_start_changed(self, checked: bool) -> None: + self._config.auto_start = checked + save_config(self._config) + if checked: + register_startup(sys.executable) + else: + remove_startup() + self.settings_changed.emit() + + @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))) diff --git a/synodic_client/application/screen/tray.py b/synodic_client/application/screen/tray.py index debb292..06fe7ed 100644 --- a/synodic_client/application/screen/tray.py +++ b/synodic_client/application/screen/tray.py @@ -2,43 +2,32 @@ import asyncio import logging -import sys from pathlib import Path from porringer.api import API from porringer.schema import SetupParameters, SyncStrategy -from PySide6.QtCore import QThread, QTimer, QUrl, Signal -from PySide6.QtGui import QAction, QDesktopServices +from PySide6.QtCore import QThread, QTimer, Signal +from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QApplication, - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QLineEdit, QMenu, QMessageBox, QProgressDialog, - QPushButton, QSystemTrayIcon, - QVBoxLayout, - QWidget, ) from synodic_client.application.icon import app_icon from synodic_client.application.screen.screen import MainWindow -from synodic_client.application.theme import UPDATE_SOURCE_DIALOG_MIN_WIDTH +from synodic_client.application.screen.settings import SettingsWindow from synodic_client.client import Client -from synodic_client.config import GlobalConfiguration, save_config -from synodic_client.logging import log_path +from synodic_client.config import GlobalConfiguration from synodic_client.resolution import ( resolve_config, resolve_enabled_plugins, resolve_update_config, update_and_resolve, ) -from synodic_client.startup import is_startup_registered, register_startup, remove_startup -from synodic_client.updater import GITHUB_REPO_URL, UpdateChannel, UpdateInfo +from synodic_client.updater import UpdateChannel, UpdateInfo logger = logging.getLogger(__name__) @@ -138,60 +127,6 @@ async def _sync(self, params: SetupParameters) -> None: pass # consume events to completion -class UpdateSourceDialog(QDialog): - """Dialog for editing the Velopack update source URL or local path.""" - - def __init__(self, current_source: str | None, parent: QWidget | None = None) -> None: - """Initialise the dialog. - - Args: - current_source: The current update source value (may be ``None``). - parent: Optional parent widget. - """ - super().__init__(parent) - self.setWindowTitle('Update Source') - self.setMinimumWidth(UPDATE_SOURCE_DIALOG_MIN_WIDTH) - - layout = QVBoxLayout(self) - - label = QLabel( - 'Enter a URL or local path for Velopack releases.\nLeave blank to use the default GitHub source.', - ) - layout.addWidget(label) - - self._source_edit = QLineEdit(current_source or '') - self._source_edit.setPlaceholderText(GITHUB_REPO_URL) - - browse_button = QPushButton('Browse...') - browse_button.clicked.connect(self._browse) - - row = QHBoxLayout() - row.addWidget(self._source_edit) - row.addWidget(browse_button) - layout.addLayout(row) - - button_row = QHBoxLayout() - ok_button = QPushButton('OK') - cancel_button = QPushButton('Cancel') - button_row.addStretch() - button_row.addWidget(ok_button) - button_row.addWidget(cancel_button) - layout.addLayout(button_row) - - ok_button.clicked.connect(self.accept) - cancel_button.clicked.connect(self.reject) - - def _browse(self) -> None: - path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory') - if path: - self._source_edit.setText(path) - - @property - def source(self) -> str | None: - """Return the trimmed source text, or ``None`` if blank.""" - return self._source_edit.text().strip() or None - - class TrayScreen: """Tray screen for the application.""" @@ -231,6 +166,13 @@ def __init__( self._build_menu(app, window) + # Settings window (created once, shown/hidden on demand) + self._settings_window = SettingsWindow(self._resolve_config()) + self._settings_window.settings_changed.connect(self._on_settings_changed) + + # MainWindow gear button → open settings + window.settings_requested.connect(self._show_settings) + # Periodic auto-update checking self._auto_update_timer: QTimer | None = None self._start_auto_update_timer() @@ -253,24 +195,15 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: self.menu.addAction(self.open_action) self.open_action.triggered.connect(window.show) - # Settings submenu - self.settings_menu = QMenu('Settings', self.menu) - self.menu.addMenu(self.settings_menu) + self.menu.addSeparator() - self.update_action = QAction('Check for Updates...', self.settings_menu) + self.update_action = QAction('Check for Updates...', self.menu) self.update_action.triggered.connect(self._on_check_updates) - self.settings_menu.addAction(self.update_action) - - self.settings_menu.addSeparator() - - # Update Source action - self.update_source_action = QAction('Update Source...', self.settings_menu) - self.update_source_action.triggered.connect(self._on_update_source) - self.settings_menu.addAction(self.update_source_action) + self.menu.addAction(self.update_action) # Update Channel submenu - self.channel_menu = QMenu('Update Channel', self.settings_menu) - self.settings_menu.addMenu(self.channel_menu) + self.channel_menu = QMenu('Update Channel', self.menu) + self.menu.addMenu(self.channel_menu) self._channel_stable_action = QAction('Stable', self.channel_menu) self._channel_stable_action.setCheckable(True) @@ -285,20 +218,11 @@ def _build_menu(self, app: QApplication, window: MainWindow) -> None: # Set initial channel check state from config self._sync_channel_checks() - self.settings_menu.addSeparator() - - # Start with Windows toggle - self._auto_start_action = QAction('Start with Windows', self.settings_menu) - self._auto_start_action.setCheckable(True) - self._auto_start_action.setChecked(is_startup_registered()) - self._auto_start_action.triggered.connect(self._on_auto_start_toggled) - self.settings_menu.addAction(self._auto_start_action) - - self.settings_menu.addSeparator() + self.menu.addSeparator() - self.open_log_action = QAction('Open Log...', self.settings_menu) - self.open_log_action.triggered.connect(self._open_log) - self.settings_menu.addAction(self.open_log_action) + self.settings_action = QAction('Settings\u2026', self.menu) + self.settings_action.triggered.connect(self._show_settings) + self.menu.addAction(self.settings_action) self.menu.addSeparator() @@ -310,14 +234,6 @@ 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: @@ -376,38 +292,26 @@ def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: self._window.raise_() self._window.activateWindow() - def _on_update_source(self) -> None: - """Open a dialog to edit the update source URL or local path.""" - config = self._resolve_config() - - parent = self._window if self._window.isVisible() else None - dialog = UpdateSourceDialog(config.update_source, parent) + def _show_settings(self) -> None: + """Show the settings window.""" + self._settings_window.show() - if dialog.exec() == QDialog.DialogCode.Accepted: - config.update_source = dialog.source - logger.info('Update source changed to: %s', dialog.source or '(default)') - self._reinitialize_updater(config) + def _on_settings_changed(self) -> None: + """React to a change made in the settings window.""" + config = self._resolve_config() + self._reinitialize_updater(config) + self._sync_channel_checks() def _on_channel_changed(self, channel: UpdateChannel) -> None: - """Handle channel selection change.""" + """Handle channel selection change from the tray submenu.""" config = self._resolve_config() config.update_channel = 'dev' if channel == UpdateChannel.DEVELOPMENT else 'stable' logger.info('Update channel changed to: %s', config.update_channel) self._sync_channel_checks() self._reinitialize_updater(config) - - def _on_auto_start_toggled(self, checked: bool) -> None: - """Handle Start with Windows toggle.""" - config = self._resolve_config() - config.auto_start = checked - save_config(config) - - if checked: - register_startup(sys.executable) - else: - remove_startup() - - logger.info('Auto-startup %s', 'enabled' if checked else 'disabled') + # Keep the settings window in sync if it is visible + if self._settings_window.isVisible(): + self._settings_window.sync_from_config() def _reinitialize_updater(self, config: GlobalConfiguration) -> None: """Re-derive update settings and restart the updater and timers.""" diff --git a/synodic_client/application/theme.py b/synodic_client/application/theme.py index f19e6b9..303016d 100644 --- a/synodic_client/application/theme.py +++ b/synodic_client/application/theme.py @@ -9,7 +9,6 @@ # --------------------------------------------------------------------------- INSTALL_PREVIEW_MIN_SIZE = (650, 400) MAIN_WINDOW_MIN_SIZE = (600, 400) -UPDATE_SOURCE_DIALOG_MIN_WIDTH = 450 # --------------------------------------------------------------------------- # Layout margins (left, top, right, bottom) @@ -275,3 +274,15 @@ '}' ) """Muted card frame used as the metadata placeholder during loading.""" + +# --------------------------------------------------------------------------- +# Settings window +# --------------------------------------------------------------------------- +SETTINGS_WINDOW_MIN_SIZE = (500, 450) +"""Minimum size (width, height) for the Settings window.""" + +SETTINGS_GEAR_STYLE = ( + 'QPushButton { border: none; font-size: 16px; padding: 2px 6px; }' + 'QPushButton:hover { background: palette(midlight); border-radius: 3px; }' +) +"""Gear button style for the MainWindow tab corner widget.""" diff --git a/tests/unit/qt/test_logging.py b/tests/unit/qt/test_logging.py index ebcd159..375ac34 100644 --- a/tests/unit/qt/test_logging.py +++ b/tests/unit/qt/test_logging.py @@ -5,7 +5,7 @@ from pathlib import Path from unittest.mock import patch -from synodic_client.application.screen.tray import TrayScreen +from synodic_client.application.screen.settings import SettingsWindow from synodic_client.config import set_dev_mode from synodic_client.logging import ( EagerRotatingFileHandler, @@ -106,7 +106,7 @@ def test_writes_to_file(tmp_path: Path) -> None: class TestOpenLog: - """Tests for TrayScreen._open_log().""" + """Tests for SettingsWindow._open_log().""" @staticmethod def test_creates_file_if_missing(tmp_path: Path) -> None: @@ -115,10 +115,10 @@ def test_creates_file_if_missing(tmp_path: Path) -> None: assert not log_file.exists() with ( - patch('synodic_client.application.screen.tray.log_path', return_value=log_file), - patch('synodic_client.application.screen.tray.QDesktopServices') as mock_ds, + patch('synodic_client.application.screen.settings.log_path', return_value=log_file), + patch('synodic_client.application.screen.settings.QDesktopServices') as mock_ds, ): - TrayScreen._open_log() + SettingsWindow._open_log() assert log_file.exists() mock_ds.openUrl.assert_called_once() @@ -129,8 +129,8 @@ def test_opens_existing_file(tmp_path: Path) -> None: log_file.write_text('existing content', encoding='utf-8') with ( - patch('synodic_client.application.screen.tray.log_path', return_value=log_file), - patch('synodic_client.application.screen.tray.QDesktopServices') as mock_ds, + patch('synodic_client.application.screen.settings.log_path', return_value=log_file), + patch('synodic_client.application.screen.settings.QDesktopServices') as mock_ds, ): - TrayScreen._open_log() + SettingsWindow._open_log() mock_ds.openUrl.assert_called_once() diff --git a/tests/unit/qt/test_settings.py b/tests/unit/qt/test_settings.py new file mode 100644 index 0000000..eb27378 --- /dev/null +++ b/tests/unit/qt/test_settings.py @@ -0,0 +1,308 @@ +"""Tests for the Settings window.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +from PySide6.QtWidgets import QApplication + +from synodic_client.application.screen.settings import SettingsWindow +from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE +from synodic_client.config import GlobalConfiguration +from synodic_client.updater import DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES, DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES + +_app = QApplication.instance() or QApplication(sys.argv) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_config(**overrides: object) -> GlobalConfiguration: + """Create a ``GlobalConfiguration`` with optional field overrides.""" + return GlobalConfiguration(**overrides) # type: ignore[arg-type] + + +def _make_window(config: GlobalConfiguration | None = None) -> SettingsWindow: + """Create a ``SettingsWindow`` without showing it.""" + cfg = config or _make_config() + window = SettingsWindow(cfg) + return window + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +class TestSettingsWindowConstruction: + """Verify that the settings window builds without errors.""" + + @staticmethod + def test_default_config() -> None: + """Window title is set correctly.""" + window = _make_window() + assert window.windowTitle() == 'Synodic Settings' + + @staticmethod + def test_minimum_size() -> None: + """Minimum size matches the theme constant.""" + window = _make_window() + assert window.minimumWidth() == SETTINGS_WINDOW_MIN_SIZE[0] + assert window.minimumHeight() == SETTINGS_WINDOW_MIN_SIZE[1] + + +# --------------------------------------------------------------------------- +# sync_from_config +# --------------------------------------------------------------------------- + + +class TestSyncFromConfig: + """Verify that controls reflect the config after sync.""" + + @staticmethod + def test_channel_stable_default() -> None: + """Default config selects the Stable channel.""" + window = _make_window(_make_config()) + window.sync_from_config() + assert window._channel_combo.currentIndex() == 0 + assert window._channel_combo.currentText() == 'Stable' + + @staticmethod + def test_channel_dev() -> None: + """Config with update_channel='dev' selects Development.""" + window = _make_window(_make_config(update_channel='dev')) + window.sync_from_config() + assert window._channel_combo.currentIndex() == 1 + assert window._channel_combo.currentText() == 'Development' + + @staticmethod + def test_update_source_blank() -> None: + """Default config leaves the source field empty.""" + window = _make_window(_make_config()) + window.sync_from_config() + assert not window._source_edit.text() + + @staticmethod + def test_update_source_set() -> None: + """Custom update source is reflected in the line edit.""" + window = _make_window(_make_config(update_source='https://example.com')) + window.sync_from_config() + assert window._source_edit.text() == 'https://example.com' + + @staticmethod + def test_auto_update_interval_default() -> None: + """Default auto-update interval shows the module default.""" + window = _make_window(_make_config()) + window.sync_from_config() + assert window._auto_update_spin.value() == DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES + + @staticmethod + def test_auto_update_interval_custom() -> None: + """Custom auto-update interval is shown in the spinbox.""" + custom_interval = 60 + window = _make_window(_make_config(auto_update_interval_minutes=custom_interval)) + window.sync_from_config() + assert window._auto_update_spin.value() == custom_interval + + @staticmethod + def test_tool_update_interval_default() -> None: + """Default tool-update interval shows the module default.""" + window = _make_window(_make_config()) + window.sync_from_config() + assert window._tool_update_spin.value() == DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES + + @staticmethod + def test_tool_update_interval_custom() -> None: + """Custom tool-update interval is shown in the spinbox.""" + custom_interval = 45 + window = _make_window(_make_config(tool_update_interval_minutes=custom_interval)) + window.sync_from_config() + assert window._tool_update_spin.value() == custom_interval + + @staticmethod + def test_detect_updates_true_default() -> None: + """Default detect_updates is checked.""" + window = _make_window(_make_config()) + window.sync_from_config() + assert window._detect_updates_check.isChecked() is True + + @staticmethod + def test_detect_updates_false() -> None: + """Disabled detect_updates is unchecked.""" + window = _make_window(_make_config(detect_updates=False)) + window.sync_from_config() + assert window._detect_updates_check.isChecked() is False + + @staticmethod + def test_auto_start_reflects_registry() -> None: + """Auto-start checkbox mirrors the OS registration state.""" + window = _make_window(_make_config()) + with patch('synodic_client.application.screen.settings.is_startup_registered', return_value=True): + window.sync_from_config() + assert window._auto_start_check.isChecked() is True + + +# --------------------------------------------------------------------------- +# Callbacks +# --------------------------------------------------------------------------- + + +class TestSettingsCallbacks: + """Verify that control changes mutate config and emit the signal.""" + + @staticmethod + def test_channel_change_to_dev() -> None: + """Switching to dev mutates config and emits settings_changed.""" + config = _make_config() + window = _make_window(config) + signal_spy = MagicMock() + window.settings_changed.connect(signal_spy) + + with patch('synodic_client.application.screen.settings.save_config'): + window._channel_combo.setCurrentIndex(1) + + assert config.update_channel == 'dev' + signal_spy.assert_called_once() + + @staticmethod + def test_channel_change_to_stable() -> None: + """Switching from dev to stable writes 'stable'.""" + config = _make_config(update_channel='dev') + window = _make_window(config) + window.sync_from_config() + + with patch('synodic_client.application.screen.settings.save_config'): + window._channel_combo.setCurrentIndex(0) + + assert config.update_channel == 'stable' + + @staticmethod + def test_source_change() -> None: + """Editing the source saves and emits.""" + config = _make_config() + window = _make_window(config) + signal_spy = MagicMock() + window.settings_changed.connect(signal_spy) + + with patch('synodic_client.application.screen.settings.save_config'): + window._source_edit.setText('https://custom.example.com') + window._on_source_changed() + + assert config.update_source == 'https://custom.example.com' + signal_spy.assert_called_once() + + @staticmethod + def test_source_blank_sets_none() -> None: + """Clearing the source field stores None.""" + config = _make_config(update_source='https://old.example.com') + window = _make_window(config) + + with patch('synodic_client.application.screen.settings.save_config'): + window._source_edit.setText('') + window._on_source_changed() + + assert config.update_source is None + + @staticmethod + def test_auto_update_interval_change() -> None: + """Changing auto-update interval saves and emits.""" + new_interval = 90 + config = _make_config() + window = _make_window(config) + signal_spy = MagicMock() + window.settings_changed.connect(signal_spy) + + with patch('synodic_client.application.screen.settings.save_config'): + window._auto_update_spin.setValue(new_interval) + + assert config.auto_update_interval_minutes == new_interval + signal_spy.assert_called_once() + + @staticmethod + def test_tool_update_interval_change() -> None: + """Changing tool-update interval saves and emits.""" + new_interval = 120 + config = _make_config() + window = _make_window(config) + signal_spy = MagicMock() + window.settings_changed.connect(signal_spy) + + with patch('synodic_client.application.screen.settings.save_config'): + window._tool_update_spin.setValue(new_interval) + + assert config.tool_update_interval_minutes == new_interval + signal_spy.assert_called_once() + + @staticmethod + def test_detect_updates_change() -> None: + """Toggling detect_updates saves and emits.""" + config = _make_config() + window = _make_window(config) + window.sync_from_config() + signal_spy = MagicMock() + window.settings_changed.connect(signal_spy) + + with patch('synodic_client.application.screen.settings.save_config'): + window._detect_updates_check.setChecked(False) + + assert config.detect_updates is False + signal_spy.assert_called_once() + + @staticmethod + def test_auto_start_registers_startup() -> None: + """Enabling auto-start calls register_startup.""" + config = _make_config() + window = _make_window(config) + + with ( + patch('synodic_client.application.screen.settings.save_config'), + patch('synodic_client.application.screen.settings.register_startup') as mock_register, + patch('synodic_client.application.screen.settings.is_startup_registered', return_value=False), + ): + window._auto_start_check.setChecked(True) + + assert config.auto_start is True + mock_register.assert_called_once() + + @staticmethod + def test_auto_start_removes_startup() -> None: + """Disabling auto-start calls remove_startup.""" + config = _make_config(auto_start=True) + window = _make_window(config) + # Manually set initial state without triggering signals + window._auto_start_check.blockSignals(True) + window._auto_start_check.setChecked(True) + window._auto_start_check.blockSignals(False) + + with ( + patch('synodic_client.application.screen.settings.save_config'), + patch('synodic_client.application.screen.settings.remove_startup') as mock_remove, + ): + window._auto_start_check.setChecked(False) + + assert config.auto_start is False + mock_remove.assert_called_once() + + +# --------------------------------------------------------------------------- +# sync_from_config does not emit signals +# --------------------------------------------------------------------------- + + +class TestSyncDoesNotEmit: + """Verify that sync_from_config does not trigger settings_changed.""" + + @staticmethod + def test_sync_no_signal() -> None: + """Syncing controls from config must not emit settings_changed.""" + config = _make_config(update_channel='dev', auto_update_interval_minutes=60) + window = _make_window(config) + signal_spy = MagicMock() + window.settings_changed.connect(signal_spy) + + window.sync_from_config() + + signal_spy.assert_not_called()