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
20 changes: 19 additions & 1 deletion synodic_client/application/screen/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from dataclasses import dataclass, field
from enum import Enum, auto
from pathlib import Path
from typing import Protocol, runtime_checkable

from porringer.schema import (
PluginInfo,
Expand Down Expand Up @@ -336,10 +337,27 @@ class _DispatchState:


# ---------------------------------------------------------------------------
# Update banner data models (from update_banner.py)
# Update view protocol & banner data models
# ---------------------------------------------------------------------------


@runtime_checkable
class UpdateView(Protocol):
"""Minimal display contract for the self-update lifecycle.

:class:`UpdateBanner` satisfies this protocol implicitly via
structural typing. The controller broadcasts state transitions
through a ``list[UpdateView]`` so that every window showing update
status stays in sync.
"""

def show_downloading(self, version: str) -> None: ...
def show_downloading_progress(self, percentage: int) -> None: ...
def show_ready(self, version: str) -> None: ...
def show_error(self, message: str) -> None: ...
def hide_banner(self) -> None: ...


class UpdateBannerState(Enum):
"""Visual states for the update banner."""

Expand Down
46 changes: 16 additions & 30 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
from synodic_client.application.icon import app_icon
from synodic_client.application.screen import _format_relative_time
from synodic_client.application.screen.card import CardFrame
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
from synodic_client.application.screen.update_banner import UpdateBanner
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
from synodic_client.logging import log_path, set_debug_level
from synodic_client.resolution import ResolvedConfig, update_user_config
from synodic_client.schema import GITHUB_REPO_URL
Expand All @@ -54,9 +55,6 @@ class SettingsWindow(QMainWindow):
check_updates_requested = Signal()
"""Emitted when the user clicks the *Check for Updates* button."""

restart_requested = Signal()
"""Emitted when the user clicks the *Restart & Update* button."""

def showEvent(self, event: QShowEvent) -> None: # noqa: N802
"""[DIAG] Log every show event with a stack trace."""
geo = self.geometry()
Expand Down Expand Up @@ -199,18 +197,13 @@ def _add_update_controls(self, content: QVBoxLayout) -> None:
self._check_updates_btn = QPushButton('Check for Updates\u2026')
self._check_updates_btn.clicked.connect(self._on_check_updates_clicked)
row.addWidget(self._check_updates_btn)
self._update_status_label = QLabel('')
self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
row.addWidget(self._update_status_label)

self._restart_btn = QPushButton('Restart \u0026 Update')
self._restart_btn.clicked.connect(self.restart_requested.emit)
self._restart_btn.hide()
row.addWidget(self._restart_btn)

row.addStretch()
content.addLayout(row)

# Embedded update banner (same widget used in the main window)
self._update_banner = UpdateBanner()
content.addWidget(self._update_banner)

# Last client update timestamp
self._last_client_update_label = QLabel('')
self._last_client_update_label.setStyleSheet('color: #808080; font-size: 11px;')
Expand Down Expand Up @@ -280,31 +273,25 @@ def sync_from_config(self) -> None:
else:
self._last_client_update_label.setText('')

def set_update_status(self, text: str, style: str = '') -> None:
"""Set the inline status text next to the *Check for Updates* button.
@property
def update_banner(self) -> UpdateBanner:
"""The embedded :class:`UpdateBanner` for this window."""
return self._update_banner

Args:
text: The status message.
style: Optional stylesheet for the label (e.g. color).
"""
self._update_status_label.setText(text)
self._update_status_label.setStyleSheet(style)
def set_last_updated(self, timestamp: str) -> None:
"""Refresh the *Last updated* label from a raw ISO timestamp."""
relative = _format_relative_time(timestamp)
self._last_client_update_label.setText(f'Last updated: {relative}')
self._last_client_update_label.setToolTip(f'Last updated: {timestamp}')

def set_checking(self) -> None:
"""Enter the *checking* state — disable button and show status."""
"""Enter the *checking* state — disable button."""
self._check_updates_btn.setEnabled(False)
self._restart_btn.hide()
self._update_status_label.setText('Checking\u2026')
self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)

def reset_check_updates_button(self) -> None:
"""Re-enable the *Check for Updates* button after a check completes."""
self._check_updates_btn.setEnabled(True)

def show_restart_button(self) -> None:
"""Show the *Restart & Update* button."""
self._restart_btn.show()

def show(self) -> None:
"""Sync controls from config, then show the window."""
self.sync_from_config()
Expand Down Expand Up @@ -350,7 +337,6 @@ def _block_signals(self) -> Iterator[None]:
def _on_check_updates_clicked(self) -> None:
"""Handle the *Check for Updates* button click."""
self._check_updates_btn.setEnabled(False)
self._update_status_label.setText('Checking\u2026')
self.check_updates_requested.emit()

def _on_channel_changed(self, index: int) -> None:
Expand Down
3 changes: 1 addition & 2 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,10 @@ def __init__(
window.settings_requested.connect(self._show_settings)

# Update controller - owns the self-update lifecycle & timer
self._banner = window.update_banner
self._update_controller = UpdateController(
app,
client,
self._banner,
[window.update_banner, self._settings_window.update_banner],
settings_window=self._settings_window,
config=config,
)
Expand Down
98 changes: 45 additions & 53 deletions synodic_client/application/update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,8 @@
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication

from synodic_client.application.screen.schema import UpdateView
from synodic_client.application.screen.update_banner import UpdateBanner
from synodic_client.application.theme import (
UPDATE_STATUS_AVAILABLE_STYLE,
UPDATE_STATUS_ERROR_STYLE,
UPDATE_STATUS_UP_TO_DATE_STYLE,
)
from synodic_client.application.workers import check_for_update, download_update
from synodic_client.resolution import (
ResolvedConfig,
Expand All @@ -48,22 +44,20 @@ class UpdateController:
The running ``QApplication`` (needed for ``quit()`` on auto-apply).
client:
The Synodic Client service facade.
banner:
The in-app ``UpdateBanner`` widget.
views:
One or more :class:`UpdateView` implementations to broadcast
state transitions to (typically ``UpdateBanner`` instances).
settings_window:
The ``SettingsWindow`` (receives status text + colour).
The ``SettingsWindow`` (check button + last-updated label).
config:
Optional pre-resolved configuration. ``None`` resolves from disk.
is_user_active:
Predicate returning ``True`` when the user has a visible window.
Auto-apply is deferred while active; checks still run normally.
"""

def __init__(
self,
app: QApplication,
client: Client,
banner: UpdateBanner,
views: list[UpdateView],
*,
settings_window: SettingsWindow,
config: ResolvedConfig | None = None,
Expand All @@ -73,13 +67,13 @@ def __init__(
Args:
app: The running ``QApplication``.
client: The Synodic Client service facade.
banner: The in-app ``UpdateBanner`` widget.
settings_window: The settings window for status feedback.
views: One or more :class:`UpdateView` implementations.
settings_window: The settings window (check button + timestamp).
config: Optional pre-resolved configuration.
"""
self._app = app
self._client = client
self._banner = banner
self._views = views
self._settings_window = settings_window
self._config = config
self._is_user_active: Callable[[], bool] = lambda: False
Expand All @@ -94,13 +88,14 @@ def __init__(
self._auto_update_timer: QTimer | None = None
self._restart_auto_update_timer()

# Wire banner signals
self._banner.restart_requested.connect(self._apply_update)
self._banner.retry_requested.connect(lambda: self.check_now(silent=True))
# Wire banner signals (UpdateBanner-specific, outside the protocol)
for view in self._views:
if isinstance(view, UpdateBanner):
view.restart_requested.connect(self._apply_update)
view.retry_requested.connect(lambda: self.check_now(silent=True))

# Wire settings check-updates button
self._settings_window.check_updates_requested.connect(self._on_manual_check)
self._settings_window.restart_requested.connect(self._apply_update)

def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
"""Set the predicate used to defer auto-apply when the user is active.
Expand Down Expand Up @@ -212,10 +207,11 @@ def _do_check(self, *, silent: bool) -> None:
"""Run an update check."""
if self._client.updater is None:
if not silent:
self._banner.show_error('Updater is not initialized.')
for view in self._views:
view.show_error('Updater is not initialized.')
return

# Preserve the restart button when an update is already pending
# Preserve the banner state when an update is already pending
if self._pending_version is None:
self._settings_window.set_checking()

Expand All @@ -237,23 +233,22 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False)
self._settings_window.reset_check_updates_button()

if result is None:
self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
if not silent:
self._banner.show_error('Failed to check for updates.')
for view in self._views:
view.show_error('Failed to check for updates.')
else:
logger.warning('Automatic update check failed (no result)')
return

if result.error:
self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
if not silent:
self._banner.show_error(result.error)
for view in self._views:
view.show_error(result.error)
else:
logger.warning('Automatic update check failed: %s', result.error)
return

if not result.available:
self._settings_window.set_update_status('Up to date', UPDATE_STATUS_UP_TO_DATE_STYLE)
if not silent:
logger.info('No updates available (current: %s)', result.current_version)
else:
Expand All @@ -268,20 +263,17 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False)
return

# New update available — download it
self._settings_window.set_update_status(
f'v{version} available',
UPDATE_STATUS_AVAILABLE_STYLE,
)
self._banner.show_downloading(version)
for view in self._views:
view.show_downloading(version)
self._start_download(version)

def _on_check_error(self, error: str, *, silent: bool = False) -> None:
"""Handle unexpected exception during update check."""
self._settings_window.reset_check_updates_button()
self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)

if not silent:
self._banner.show_error(f'Update check error: {error}')
for view in self._views:
view.show_error(f'Update check error: {error}')
else:
logger.warning('Automatic update check error: %s', error)

Expand All @@ -298,50 +290,49 @@ async def _async_download(self, version: str) -> None:
try:
success = await download_update(
self._client,
on_progress=self._banner.show_downloading_progress,
on_progress=self._on_download_progress,
)
self._on_download_finished(success, version)
except Exception as exc:
logger.exception('Update download failed')
self._on_download_error(str(exc))

def _on_download_progress(self, percentage: int) -> None:
"""Broadcast download progress to all views."""
for view in self._views:
view.show_downloading_progress(percentage)

def _on_download_finished(self, success: bool, version: str) -> None:
"""Handle download completion."""
if not success:
self._banner.show_error('Download failed. Please try again later.')
self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
for view in self._views:
view.show_error('Download failed. Please try again later.')
return

# Persist the client update timestamp
update_user_config(last_client_update=datetime.now(UTC).isoformat())
# Persist and display the client update timestamp
ts = datetime.now(UTC).isoformat()
update_user_config(last_client_update=ts)
self._settings_window.set_last_updated(ts)

self._pending_version = version

if self._can_auto_apply():
# Silently apply and restart — no banner, no user interaction
logger.info('Auto-applying update v%s', version)
self._settings_window.set_update_status(
f'v{version} installing\u2026',
UPDATE_STATUS_AVAILABLE_STYLE,
)
self._apply_update(silent=True)
return

self._show_ready(version)

def _show_ready(self, version: str) -> None:
"""Present the *ready to restart* state in both UIs."""
self._banner.show_ready(version)
self._settings_window.set_update_status(
f'v{version} ready',
UPDATE_STATUS_UP_TO_DATE_STYLE,
)
self._settings_window.show_restart_button()
"""Present the *ready to restart* state across all views."""
for view in self._views:
view.show_ready(version)

def _on_download_error(self, error: str) -> None:
"""Handle download error — show error banner."""
self._banner.show_error(f'Download error: {error}')
self._settings_window.set_update_status('Download failed', UPDATE_STATUS_ERROR_STYLE)
"""Handle download error — show error across all views."""
for view in self._views:
view.show_error(f'Download error: {error}')

# ------------------------------------------------------------------
# Apply
Expand All @@ -364,4 +355,5 @@ def _apply_update(self, *, silent: bool = False) -> None:
self._app.quit()
except Exception as e:
logger.error('Failed to apply update: %s', e)
self._banner.show_error(f'Failed to apply update: {e}')
for view in self._views:
view.show_error(f'Failed to apply update: {e}')
Loading
Loading