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()
+
+
+
+
+
+
+