diff --git a/bcc.py b/bcc.py index 599eaac..eb3d12e 100644 --- a/bcc.py +++ b/bcc.py @@ -11,13 +11,23 @@ Run: python mcp_manager.py from __future__ import annotations import sys +import time from pathlib import Path -from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, Signal -from PySide6.QtGui import QAction, QColor, QGuiApplication, QPainter +from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal +from PySide6.QtGui import ( + QAction, + QColor, + QDesktopServices, + QGuiApplication, + QIcon, + QPainter, + QPixmap, +) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -147,6 +157,23 @@ class SpawnTester(QThread): self.done.emit(result) +class UpdateCheckWorker(QThread): + """ + Fetches the latest release from the Gitea releases API off the UI thread. + + Notify-only: `core.fetch_latest_release()` only ever reads release + metadata (a version tag + a URL) and never downloads or replaces the + running binary. Fails quiet — emits None on any network problem — so + it's safe to fire unattended from a silent startup check as well as from + the About dialog's "Check for updates" button. + """ + + done = Signal(object) # dict | None + + def run(self): + self.done.emit(core.fetch_latest_release()) + + # --------------------------------------------------------------------------- # # Small reusable: key/value editor (for env and headers) # --------------------------------------------------------------------------- # @@ -1090,6 +1117,153 @@ class PasteDialog(QDialog): self.err.setText(str(e)) +# --------------------------------------------------------------------------- # +# About dialog +# --------------------------------------------------------------------------- # +class AboutDialog(QDialog): + """ + App info + a manual "Check for updates" action. + + Shows nothing sensitive: app name, icon, version (from + core.__version__), and links opened in the system browser via + QDesktopServices.openUrl — never navigated to in-app. The update check + itself only ever reads release metadata (see UpdateCheckWorker); it + never downloads or replaces the running binary. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Better Claude Config") + self.setFixedWidth(440) + self._worker: UpdateCheckWorker | None = None + self._release_url: str | None = None + + icon_path = ( + Path(__file__).resolve().parent / "icons" / "twin-gears" / "rounded" / "icon-128.png" + ) + if icon_path.is_file(): + self.setWindowIcon(QIcon(str(icon_path))) + + v = QVBoxLayout(self) + v.setSpacing(10) + + head = QHBoxLayout() + head.setSpacing(12) + icon_lbl = QLabel() + if icon_path.is_file(): + icon_lbl.setPixmap( + QPixmap(str(icon_path)).scaled( + 56, + 56, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + head.addWidget(icon_lbl) + + title_box = QVBoxLayout() + title_box.setSpacing(2) + name_lbl = QLabel("Better Claude Config") + name_lbl.setObjectName("h1") + title_box.addWidget(name_lbl) + ver_lbl = QLabel(f"Version {core.__version__}") + ver_lbl.setObjectName("muted") + title_box.addWidget(ver_lbl) + head.addLayout(title_box, 1) + v.addLayout(head) + + desc = QLabel( + "A cross-platform GUI for editing the mcpServers block of Claude " + "Desktop and Claude Code configs." + ) + desc.setObjectName("muted") + desc.setWordWrap(True) + v.addWidget(desc) + + if sys.platform == "darwin": + note = QLabel( + "This build isn't notarized by Apple. If macOS blocks it on first " + "launch, right-click the app ▸ Open, or allow it under System " + "Settings ▸ Privacy & Security." + ) + note.setObjectName("muted") + note.setWordWrap(True) + v.addWidget(note) + + links = QHBoxLayout() + for text, url in ( + ("Repository", core.REPO_URL), + ("Issues", core.ISSUES_URL), + ("MIT License", core.LICENSE_URL), + ): + btn = QPushButton(text) + btn.setFlat(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u))) + links.addWidget(btn) + links.addStretch() + v.addLayout(links) + + # --- update check ----------------------------------------------- # + upd_row = QHBoxLayout() + self.check_btn = QPushButton("Check for updates") + self.check_btn.clicked.connect(self._check_for_updates) + upd_row.addWidget(self.check_btn) + self.update_status = QLabel("") + self.update_status.setObjectName("muted") + self.update_status.setWordWrap(True) + upd_row.addWidget(self.update_status, 1) + v.addLayout(upd_row) + + self.release_btn = QPushButton("Open releases page") + self.release_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.release_btn.clicked.connect(self._open_release_page) + self.release_btn.hide() + v.addWidget(self.release_btn) + + self.auto_check_box = QCheckBox("Automatically check for updates on startup") + st = QSettings("BCC", "BetterClaudeConfig") + self.auto_check_box.setChecked(bool(st.value("update/autoCheck", True, type=bool))) + self.auto_check_box.toggled.connect(self._toggle_auto_check) + v.addWidget(self.auto_check_box) + + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(self.reject) + btns.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.accept) + v.addWidget(btns) + + def _toggle_auto_check(self, on: bool): + QSettings("BCC", "BetterClaudeConfig").setValue("update/autoCheck", on) + + def _check_for_updates(self): + self.check_btn.setEnabled(False) + self.release_btn.hide() + self.update_status.setStyleSheet(f"color: {MUTED};") + self.update_status.setText("Checking…") + self._worker = UpdateCheckWorker() + self._worker.done.connect(self._on_check_done) + self._worker.start() + + def _on_check_done(self, release: dict | None): + self.check_btn.setEnabled(True) + self._worker = None + if not release: + self.update_status.setStyleSheet(f"color: {MUTED};") + self.update_status.setText("Couldn't check for updates (offline?).") + return + if core.is_newer_version(core.__version__, release["version"]): + self.update_status.setStyleSheet(f"color: {ACCENT};") + self.update_status.setText(f"{release['version']} available.") + self._release_url = release.get("url") or core.RELEASES_URL + self.release_btn.show() + else: + self.update_status.setStyleSheet(f"color: {GOOD};") + self.update_status.setText("You're up to date.") + + def _open_release_page(self): + QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL)) + + # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- # @@ -1099,6 +1273,7 @@ class MainWindow(QMainWindow): self.setWindowTitle("Better Claude Config") self.resize(940, 640) self.setAcceptDrops(True) + self._build_menu_bar() self.profiles: list[core.Profile] = [] self.full_config: dict = {} @@ -1141,6 +1316,37 @@ class MainWindow(QMainWindow): self._restore_layout() self.reload_profiles() + self._maybe_auto_check_updates() + + # --- menu bar ---------------------------------------------------------- # + def _build_menu_bar(self): + help_menu = self.menuBar().addMenu("&Help") + about_action = QAction("About Better Claude Config…", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _show_about(self): + AboutDialog(self).exec() + + # --- update check (silent, throttled, off-thread) --------------------- # + def _maybe_auto_check_updates(self): + st = QSettings("BCC", "BetterClaudeConfig") + if not bool(st.value("update/autoCheck", True, type=bool)): + return + last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0) + if (time.time() - last) < 86400: # at most once/day + return + st.setValue("update/lastCheck", time.time()) + self._startup_update_worker = UpdateCheckWorker() + self._startup_update_worker.done.connect(self._on_startup_update_checked) + self._startup_update_worker.start() + + def _on_startup_update_checked(self, release: dict | None): + self._startup_update_worker = None + if release and core.is_newer_version(core.__version__, release["version"]): + self.status.setText( + f"Update available: {release['version']} · Help ▸ About to view it." + ) # --- layout persistence ---------------------------------------------- # def _restore_layout(self):