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
24 changes: 19 additions & 5 deletions synodic_client/application/screen/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,25 @@ class UpdateView(Protocol):
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: ...
def show_downloading(self, version: str) -> None:
"""Indicate that *version* is being downloaded."""
...

def show_downloading_progress(self, percentage: int) -> None:
"""Update the download progress indicator."""
...

def show_ready(self, version: str) -> None:
"""Indicate that *version* is downloaded and ready to install."""
...

def show_error(self, message: str) -> None:
"""Display an error *message* in the update area."""
...

def hide_banner(self) -> None:
"""Hide the update banner."""
...


class UpdateBannerState(Enum):
Expand Down
52 changes: 36 additions & 16 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@
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.screen.update_banner import UpdateBanner
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
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 @@ -55,6 +54,9 @@ 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 @@ -197,13 +199,18 @@ 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 @@ -273,25 +280,37 @@ def sync_from_config(self) -> None:
else:
self._last_client_update_label.setText('')

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

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}')
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_checking(self) -> None:
"""Enter the *checking* state — disable button."""
"""Enter the *checking* state — disable button and show status."""
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 set_last_checked(self, timestamp: str) -> None:
"""Update the *last updated* label from an ISO 8601 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 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 @@ -337,6 +356,7 @@ 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: 2 additions & 1 deletion synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,11 @@ 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,
[window.update_banner, self._settings_window.update_banner],
[self._banner],
settings_window=self._settings_window,
config=config,
)
Expand Down
57 changes: 38 additions & 19 deletions synodic_client/application/update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@

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 Down Expand Up @@ -94,8 +99,9 @@ def __init__(
view.restart_requested.connect(self._apply_update)
view.retry_requested.connect(lambda: self.check_now(silent=True))

# Wire settings check-updates button
# Wire settings check-updates and restart buttons
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 @@ -123,6 +129,25 @@ def _can_auto_apply(self) -> bool:
"""
return self._auto_apply and not self._is_user_active()

def _persist_check_timestamp(self) -> None:
"""Persist the current time as *last_client_update* and refresh the label."""
ts = datetime.now(UTC).isoformat()
update_user_config(last_client_update=ts)
self._settings_window.set_last_checked(ts)

def _report_error(self, message: str, *, silent: bool) -> None:
"""Show an error to the user or log it, depending on *silent*.

Always updates the settings status line. When not *silent*,
also broadcasts the error to all update-banner views.
"""
self._settings_window.set_update_status('Check failed', UPDATE_STATUS_ERROR_STYLE)
if silent:
logger.warning('%s', message)
else:
for view in self._views:
view.show_error(message)

# ------------------------------------------------------------------
# Timer management
# ------------------------------------------------------------------
Expand Down Expand Up @@ -233,22 +258,18 @@ def _on_check_finished(self, result: UpdateInfo | None, *, silent: bool = False)
self._settings_window.reset_check_updates_button()

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

if result.error:
if not silent:
for view in self._views:
view.show_error(result.error)
else:
logger.warning('Automatic update check failed: %s', result.error)
self._report_error(result.error, silent=silent)
return

# Successful check — refresh the "last updated" timestamp
self._persist_check_timestamp()

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 @@ -263,19 +284,15 @@ 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)
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()

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

# ------------------------------------------------------------------
# Download flow
Expand Down Expand Up @@ -305,14 +322,14 @@ def _on_download_progress(self, percentage: int) -> None:
def _on_download_finished(self, success: bool, version: str) -> None:
"""Handle download completion."""
if not success:
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 and display the client update timestamp
# Persist the client-update timestamp (actual update downloaded)
ts = datetime.now(UTC).isoformat()
update_user_config(last_client_update=ts)
self._settings_window.set_last_updated(ts)

self._pending_version = version

Expand All @@ -326,6 +343,8 @@ def _on_download_finished(self, success: bool, version: str) -> None:

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

Expand Down
24 changes: 16 additions & 8 deletions tests/unit/qt/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,18 +334,16 @@ def test_sync_no_signal() -> None:


class TestCheckForUpdatesButton:
"""Verify the Check for Updates button and embedded update banner."""
"""Verify the Check for Updates button and inline status label."""

@staticmethod
def test_button_and_banner_exist() -> None:
"""Window has the check-updates button and an embedded UpdateBanner."""
def test_button_and_label_exist() -> None:
"""Window has the check-updates button and status label."""
window = _make_window()
assert hasattr(window, '_check_updates_btn')
assert hasattr(window, '_update_status_label')
assert window._check_updates_btn.text() == 'Check for Updates\u2026'
assert hasattr(window, '_update_banner')
from synodic_client.application.screen.update_banner import UpdateBanner

assert isinstance(window.update_banner, UpdateBanner)
assert not window._update_status_label.text()

@staticmethod
def test_click_emits_signal_and_disables() -> None:
Expand All @@ -358,6 +356,15 @@ def test_click_emits_signal_and_disables() -> None:

signal_spy.assert_called_once()
assert window._check_updates_btn.isEnabled() is False
assert window._update_status_label.text() == 'Checking\u2026'

@staticmethod
def test_set_update_status() -> None:
"""set_update_status sets the label text and style."""
window = _make_window()
window.set_update_status('Up to date', 'color: green;')
assert window._update_status_label.text() == 'Up to date'
assert 'green' in window._update_status_label.styleSheet()

@staticmethod
def test_reset_check_updates_button() -> None:
Expand All @@ -371,7 +378,8 @@ def test_reset_check_updates_button() -> None:

@staticmethod
def test_set_checking() -> None:
"""set_checking disables the button."""
"""set_checking disables the button and shows 'Checking\u2026' status."""
window = _make_window()
window.set_checking()
assert window._check_updates_btn.isEnabled() is False
assert window._update_status_label.text() == 'Checking\u2026'
Loading