Initial version by @greatdaveo

This commit is contained in:
Raivis Dejus 2026-02-28 09:19:51 +02:00
commit 5bad412804
7 changed files with 514 additions and 0 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M160-200v-60h640v60H160Zm320-136L280-536l42-42 128 128v-310h60v310l128-128 42 42-200 200Z" transform="rotate(180 480 -480)"/></svg>

After

Width:  |  Height:  |  Size: 229 B

View file

@ -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:

180
buzz/update_checker.py Normal file
View file

@ -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

View file

@ -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")

View file

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

View file

@ -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)

View file

@ -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"<b>{VERSION}</b>")
new_version_label = QLabel(_("New version:"))
new_version_value = QLabel(f"<b>{self.update_info.version}</b>")
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()