From 5bad412804d2b9125686116832abc2d6da28364e Mon Sep 17 00:00:00 2001 From: Raivis Dejus Date: Sat, 28 Feb 2026 09:19:51 +0200 Subject: [PATCH] Initial version by @greatdaveo --- .../update_FILL0_wght700_GRAD0_opsz48.svg | 1 + buzz/settings/settings.py | 3 + buzz/update_checker.py | 180 ++++++++++++ buzz/widgets/icon.py | 1 + buzz/widgets/main_window.py | 39 +++ buzz/widgets/main_window_toolbar.py | 17 ++ buzz/widgets/update_dialog.py | 273 ++++++++++++++++++ 7 files changed, 514 insertions(+) create mode 100644 buzz/assets/update_FILL0_wght700_GRAD0_opsz48.svg create mode 100644 buzz/update_checker.py create mode 100644 buzz/widgets/update_dialog.py diff --git a/buzz/assets/update_FILL0_wght700_GRAD0_opsz48.svg b/buzz/assets/update_FILL0_wght700_GRAD0_opsz48.svg new file mode 100644 index 00000000..d01ac5a7 --- /dev/null +++ b/buzz/assets/update_FILL0_wght700_GRAD0_opsz48.svg @@ -0,0 +1 @@ + diff --git a/buzz/settings/settings.py b/buzz/settings/settings.py index 4e722495..23f96b06 100644 --- a/buzz/settings/settings.py +++ b/buzz/settings/settings.py @@ -82,6 +82,9 @@ class Settings: FORCE_CPU = "force-cpu" REDUCE_GPU_MEMORY = "reduce-gpu-memory" + LAST_UPDATE_CHECK = "update/last-check" + UPDATE_AVAILABLE_VERSION = "update/available-version" + def get_user_identifier(self) -> str: user_id = self.value(self.Key.USER_IDENTIFIER, "") if not user_id: diff --git a/buzz/update_checker.py b/buzz/update_checker.py new file mode 100644 index 00000000..4e5e0d39 --- /dev/null +++ b/buzz/update_checker.py @@ -0,0 +1,180 @@ +import json +import logging +import platform +from datetime import datetime, timedelta +from typing import Optional, Callable +from dataclasses import dataclass + +from PyQt6.QtCore import QObject, pyqtSignal, QUrl +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from sympy import true + +from buzz.__version__ import VERSION +from buzz.settings.settings import Settings + + +@dataclass +class UpdateInfo: + version: str + release_notes: str + download_url: str + +class UpdateChecker(QObject): + update_available = pyqtSignal(object) + + no_update_available = pyqtSignal() + + check_failed = pyqtSignal(str) + + VERSION_JSON_URL = "https://raw.githubusercontent.com/chidiwilliams/buzz/refs/heads/main/version.json" + + CHECK_INTERVAL_DAYS = 7 + + def __init__( + self, + settings: Settings, + network_manager: Optional[QNetworkAccessManager] = None, + parent: Optional[QObject] = None + ): + super().__init__(parent) + + self.settings = settings + + if network_manager is None: + network_manager = QNetworkAccessManager(self) + self.network_manager = network_manager + self.network_manager.finished.connect(self._on_reply_finished) + + self._force_check = False + + + def should_check_for_updates(self) -> bool: + """"Check if we are on Windows/macOS and if 7 days passed""" + system = platform.system() + if system not in ("Windows", "Darwin"): + logging.debug("Skipping update check on linux") + return False + + last_check = self.settings.value( + Settings.Key.LAST_UPDATE_CHECK, + "", + ) + + if last_check: + try: + last_check_date = datetime.fromisoformat(last_check) + days_since_check = (datetime.now() - last_check_date).days + if days_since_check < self.CHECK_INTERVAL_DAYS: + logging.debug( + f"Skipping update check, last checked {days_since_check} days ago" + ) + return False + except ValueError: + #Invalid date format + pass + + return True + + def check_for_updates(self, force: bool = False) -> None: + """Start the network request""" + self._force_check = force + + if not force and not self.should_check_for_updates(): + self.no_update_available.emit() + return + + logging.info("Checking for updates...") + + url = QUrl(self.VERSION_JSON_URL) + request = QNetworkRequest(url) + self.network_manager.get(request) + + def _on_reply_finished(self, reply: QNetworkReply) -> None: + """Handles the network reply for version.json fetch""" + self.settings.set_value( + Settings.Key.LAST_UPDATE_CHECK, + datetime.now().isoformat() + ) + + if reply.error() != QNetworkReply.NetworkError.NoError: + error_msg = f"Failed to check for updates: {reply.errorString()}" + logging.error(error_msg) + self.check_failed.emit(error_msg) + reply.deleteLater() + return + + try: + data = json.loads(reply.readAll().data().decode("utf-8")) + reply.deleteLater() + + remote_version = data.get("version", "") + release_notes = data.get("release_notes", "") + download_urls = data.get("download_urls", {}) + + #Get the download url for current platform + download_url = self._get_download_url(download_urls) + + if self._is_newer_version(remote_version): + logging.info(f"Update available: {remote_version}") + + #Store the available version + self.settings.set_value( + Settings.Key.UPDATE_AVAILABLE_VERSION, + remote_version + ) + + update_info = UpdateInfo( + version=remote_version, + release_notes=release_notes, + download_url=download_url + ) + self.update_available.emit(update_info) + + else: + logging.info("No update available") + self.settings.set_value( + Settings.Key.UPDATE_AVAILABLE_VERSION, + "" + ) + self.no_update_available.emit() + + except (json.JSONDecodeError, KeyError) as e: + error_msg = f"Failed to parse version info: {e}" + logging.error(error_msg) + self.check_failed.emit(error_msg) + + def _get_download_url(self, download_urls: dict) -> str: + system = platform.system() + machine = platform.machine().lower() + + if system == "Windows": + return download_urls.get("windows_x64", "") + elif system == "Darwin": + if machine in ("arm64", "aarch64"): + return download_urls.get("macos_arm", "") + else: + return download_urls.get("macos_x86", "") + + return "" + + def _is_newer_version(self, remote_version: str) -> bool: + """Compare remote version with current version""" + try: + current_parts = [int(x) for x in VERSION.split(".")] + remote_parts = [int(x) for x in remote_version.split(".")] + + #pad with zeros if needed + while len(current_parts) < len(remote_parts): + current_parts.append(0) + while len(remote_parts) < len(current_parts): + remote_parts.append(0) + + return remote_parts > current_parts + + except ValueError: + logging.error(f"Invalid version format: {VERSION} or {remote_version}") + return False + + + + diff --git a/buzz/widgets/icon.py b/buzz/widgets/icon.py index 298232a1..12718725 100644 --- a/buzz/widgets/icon.py +++ b/buzz/widgets/icon.py @@ -129,3 +129,4 @@ ADD_ICON_PATH = get_path("assets/add_FILL0_wght700_GRAD0_opsz48.svg") URL_ICON_PATH = get_path("assets/url.svg") TRASH_ICON_PATH = get_path("assets/delete_FILL0_wght700_GRAD0_opsz48.svg") CANCEL_ICON_PATH = get_path("assets/cancel_FILL0_wght700_GRAD0_opsz48.svg") +UPDATE_ICON_PATH = get_path("assets/update_FILL0_wght700_GRAD0_opsz48.svg") \ No newline at end of file diff --git a/buzz/widgets/main_window.py b/buzz/widgets/main_window.py index 653d178e..30625302 100644 --- a/buzz/widgets/main_window.py +++ b/buzz/widgets/main_window.py @@ -24,6 +24,8 @@ from buzz.db.service.transcription_service import TranscriptionService from buzz.file_transcriber_queue_worker import FileTranscriberQueueWorker from buzz.locale import _ from buzz.settings.settings import APP_NAME, Settings +from buzz.update_checker import UpdateChecker, UpdateInfo +from buzz.widgets.update_dialog import UpdateDialog from buzz.settings.shortcuts import Shortcuts from buzz.store.keyring_store import set_password, Key from buzz.transcriber.transcriber import ( @@ -70,6 +72,9 @@ class MainWindow(QMainWindow): self.quit_on_complete = False self.transcription_service = transcription_service + #update checker + self._update_info: Optional[UpdateInfo] = None + self.toolbar = MainWindowToolbar(shortcuts=self.shortcuts, parent=self) self.toolbar.new_transcription_action_triggered.connect( self.on_new_transcription_action_triggered @@ -87,6 +92,7 @@ class MainWindow(QMainWindow): self.on_stop_transcription_action_triggered ) self.addToolBar(self.toolbar) + self.toolbar.update_action_triggered.connect(self.on_update_action_triggered) self.setUnifiedTitleAndToolBarOnMac(True) self.preferences = self.load_preferences(settings=self.settings) @@ -156,6 +162,9 @@ class MainWindow(QMainWindow): self.transcription_viewer_widget = None + #Initialize and run update checker + self._init_update_checker() + def on_preferences_changed(self, preferences: Preferences): self.preferences = preferences self.save_preferences(preferences) @@ -493,3 +502,33 @@ class MainWindow(QMainWindow): self.setBaseSize(1240, 600) self.resize(1240, 600) self.settings.end_group() + + def _init_update_checker(self): + """Initializes and runs the update checker.""" + self.update_checker = UpdateChecker(settings=self.settings, parent=self) + self.update_checker.update_available.connect(self._on_update_available) + self.update_checker.check_failed.connect(self._on_update_check_failed) + + # Check for updates on startup + self.update_checker.check_for_updates() + + def _on_update_available(self, update_info: UpdateInfo): + """Called when an update is available.""" + logging.info(f"Update available: {update_info.version}") + self._update_info = update_info + self.toolbar.set_update_available(True) + + def _on_update_check_failed(self, error: str): + """Called when update check fails.""" + logging.warning(f"Update check failed: {error}") + + def on_update_action_triggered(self): + """Called when user clicks the update action in toolbar.""" + if self._update_info is None: + return + + dialog = UpdateDialog( + update_info=self._update_info, + parent=self + ) + dialog.exec() \ No newline at end of file diff --git a/buzz/widgets/main_window_toolbar.py b/buzz/widgets/main_window_toolbar.py index fc982ac0..fdbc8a2e 100644 --- a/buzz/widgets/main_window_toolbar.py +++ b/buzz/widgets/main_window_toolbar.py @@ -16,6 +16,7 @@ from buzz.widgets.icon import ( EXPAND_ICON_PATH, CANCEL_ICON_PATH, TRASH_ICON_PATH, + UPDATE_ICON_PATH, ) from buzz.widgets.recording_transcriber_widget import RecordingTranscriberWidget from buzz.widgets.toolbar import ToolBar @@ -26,6 +27,7 @@ class MainWindowToolbar(ToolBar): new_url_transcription_action_triggered: pyqtSignal open_transcript_action_triggered: pyqtSignal clear_history_action_triggered: pyqtSignal + update_action_triggered: pyqtSignal ICON_LIGHT_THEME_BACKGROUND = "#555" ICON_DARK_THEME_BACKGROUND = "#AAA" @@ -70,6 +72,13 @@ class MainWindowToolbar(ToolBar): self.clear_history_action = Action( Icon(TRASH_ICON_PATH, self), _("Clear History"), self ) + + self.update_action = Action( + Icon(UPDATE_ICON_PATH, self), _("Update Available"), self + ) + self.update_action_triggered = self.update_action.triggered + self.update_action.setVisible(False) + self.clear_history_action_triggered = self.clear_history_action.triggered self.clear_history_action.setDisabled(True) @@ -86,6 +95,10 @@ class MainWindowToolbar(ToolBar): self.clear_history_action, ] ) + + self.addSeparator() + self.addAction(self.update_action) + self.setMovable(False) self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) @@ -114,3 +127,7 @@ class MainWindowToolbar(ToolBar): def set_clear_history_action_enabled(self, enabled: bool): self.clear_history_action.setEnabled(enabled) + + def set_update_available(self, available: bool): + """Shows or hides the update action in the toolbar.""" + self.update_action.setVisible(available) diff --git a/buzz/widgets/update_dialog.py b/buzz/widgets/update_dialog.py new file mode 100644 index 00000000..c1c50e9f --- /dev/null +++ b/buzz/widgets/update_dialog.py @@ -0,0 +1,273 @@ +import logging +import os +import platform +import subprocess +import tempfile +from typing import Optional + +from PyQt6.QtCore import Qt, QUrl +from PyQt6.QtGui import QIcon +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QProgressBar, + QMessageBox, + QWidget, + QTextEdit, +) + +from buzz.__version__ import VERSION +from buzz.locale import _ +from buzz.update_checker import UpdateInfo +from buzz.widgets.icon import BUZZ_ICON_PATH + +class UpdateDialog(QDialog): + """Dialog shows when an update is available""" + def __init__( + self, + update_info: UpdateInfo, + network_manager: Optional[QNetworkAccessManager] = None, + parent: Optional[QWidget] = None + ): + super().__init__(parent) + + self.update_info = update_info + + if network_manager is None: + network_manager = QNetworkAccessManager(self) + self.network_manager = network_manager + + self._download_reply: Optional[QNetworkReply] = None + self._temp_file_path: Optional[str] = None + + self._setup_ui() + + + def _setup_ui(self): + self.setWindowTitle(_("Update Available")) + self.setWindowIcon(QIcon(BUZZ_ICON_PATH)) + self.setMinimumWidth(450) + + layout = QVBoxLayout(self) + layout.setSpacing(16) + + #header + header_label = QLabel( + _("A new version of Buzz is available!") + ) + + header_label.setStyleSheet("font-size: 16px; font-weight: bold;") + layout.addWidget(header_label) + + #Version info + version_layout = QHBoxLayout() + + current_version_label = QLabel(_("Current version:")) + current_version_value = QLabel(f"{VERSION}") + + new_version_label = QLabel(_("New version:")) + new_version_value = QLabel(f"{self.update_info.version}") + new_version_value.setStyleSheet("color: green;") + + version_layout.addWidget(current_version_label) + version_layout.addWidget(current_version_value) + version_layout.addStretch() + version_layout.addWidget(new_version_label) + version_layout.addWidget(new_version_value) + + layout.addLayout(version_layout) + + #Release notes + if self.update_info.release_notes: + notes_label = QLabel(_("Release Notes:")) + notes_label.setStyleSheet("font-weight: bold;") + layout.addWidget(notes_label) + + notes_text = QTextEdit() + notes_text.setReadOnly(True) + notes_text.setMarkdown(self.update_info.release_notes) + notes_text.setMaximumHeight(150) + layout.addWidget(notes_text) + + #progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + self.progress_bar.setTextVisible(True) + layout.addWidget(self.progress_bar) + + #Status label + self.status_label = QLabel("") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.status_label) + + #Buttons + button_layout = QVBoxLayout() + + self.download_button = QPushButton(_("Download and Install")) + self.download_button.clicked.connect(self._on_download_clicked) + self.download_button.setDefault(True) + + self.cancel_button = QPushButton(_("Later")) + self.cancel_button.clicked.connect(self.reject) + + button_layout.addStretch() + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.download_button) + + layout.addLayout(button_layout) + + def _on_download_clicked(self): + """Starts downloading the installer""" + if not self.update_info.download_url: + QMessageBox.warning( + self, + _("Error"), + _("No download URL available for your platform.") + ) + return + + self.download_button.setEnabled(False) + self.cancel_button.setText(_("Cancel")) + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + self.status_label.setText(_("Downloading...")) + + url = QUrl(self.update_info.download_url) + request = QNetworkRequest(url) + + self._download_reply = self.network_manager.get(request) + self._download_reply.downloadProgress.connect(self._on_download_progress) + self._download_reply.finished.connect(self._on_download_finished) + + def _on_download_progress(self, bytes_received: int, bytes_total: int): + """Update the progress bar during download""" + if bytes_total > 0: + progress = int((bytes_received / bytes_total) * 100) + self.progress_bar.setValue(progress) + + #show size info + mb_received = bytes_received / (1024 * 1024) + mb_total = bytes_total / (1024 * 1024) + self.status_label.setText( + _("Downloading... {:.1f} MB / {:.1f} MB").format(mb_received, mb_total) + ) + + def _on_download_finished(self): + """Handles download completion""" + if self._download_reply is None: + return + + if self._download_reply.error() != QNetworkReply.NetworkError.NoError: + error_msg = self._download_reply.errorString() + logging.error(f"Download failed: {error_msg}") + + QMessageBox.critical( + self, + _("Download Failed"), + _("Failed to download the update: {}").format(error_msg) + ) + + self._reset_ui() + self._download_reply.deleteLater() + self._download_reply = None + return + + #save to temp file + data = self._download_reply.readAll().data() + self._download_reply.deleteLater() + self._download_reply = None + + #determine file extension based on platform + system = platform.system() + if system == "Windows": + suffix = ".exe" + elif system == "Darwin": + suffix = ".dmg" + else: + suffix = "" + + try: + #temp file + fd, self._temp_file_path = tempfile.mkstemp(suffix=suffix) + with os.fdopen(fd, "wb") as f: + f.write(data) + + logging.info(f"Installer saved to: {self._temp_file_path}") + + self.progress_bar.setValue(100) + self.status_label.setText(_("Download complete!")) + + #run the installer + self._run_installer() + + except Exception as e: + logging.error(f"Failed to save installer: {e}") + QMessageBox.critical( + self, + _("Error"), + _("Failed to save the installer: {}").format(str(e)) + ) + self._reset_ui() + + + def _run_installer(self): + """Run the downloaded installer""" + if not self._temp_file_path: + return + + system = platform.system() + + try: + if system == "Windows": + self.status_label.setText(_("Launching installer...")) + subprocess.Popen([self._temp_file_path], shell=True) + self.accept() + + elif system == "Darwin": + #open the DMG file + self.status_label.setText(_("Opening disk image...")) + subprocess.Popen(["open", self._temp_file_path]) + + QMessageBox.information( + self, + _("Install Update"), + _("The disk image has been opened. Please drag Buzz to your Applications folder to complete the update.") + ) + self.accept() + + except Exception as e: + logging.error(f"Failed to run installer: {e}") + QMessageBox.critical( + self, + _("Error"), + _("Failed to run the installer: {}").format(str(e)) + ) + + + def _reset_ui(self): + """Reset the UI to initial state after an error""" + self.download_button.setEnabled(True) + self.cancel_button.setText(_("Later")) + self.progress_bar.setVisible(False) + self.status_label.setText("") + + + def reject(self): + """Cancel download in progress when user clicks Cancel or Later""" + if self._download_reply is not None: + self._download_reply.abort() + self._download_reply.deleteLater() + self._download_reply = None + + super().reject() + + + + + + +