diff --git a/bcc.py b/bcc.py index 1d17d13..7b35250 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) # --------------------------------------------------------------------------- # @@ -1220,6 +1247,153 @@ class LogViewerDialog(QDialog): super().closeEvent(event) +# --------------------------------------------------------------------------- # +# 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 # --------------------------------------------------------------------------- # @@ -1229,6 +1403,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 = {} @@ -1281,6 +1456,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): diff --git a/bcc_core.py b/bcc_core.py index fd79671..98c9a48 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -47,6 +47,112 @@ MAX_BACKUPS = 15 KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} +# --------------------------------------------------------------------------- # +# Version / update checking +# +# __version__ is the single source of truth for the app version (must match +# pyproject.toml's [project] version). The About dialog and the update +# checker both read this constant instead of hard-coding a version string. +# +# The update checker is notify-only: it reads release metadata from the +# repo's Gitea releases API and NEVER downloads or replaces the running +# binary. All network I/O here is fail-quiet (returns None on any problem) +# so it's safe to run unattended, off the UI thread, at startup. +# --------------------------------------------------------------------------- # +__version__ = "1.1.0" + +REPO_URL = "https://git.avezzano.io/the_og/better-claude-config" +ISSUES_URL = f"{REPO_URL}/issues" +RELEASES_URL = f"{REPO_URL}/releases" +LICENSE_URL = f"{REPO_URL}/raw/branch/main/LICENSE" + +# Public repo -> anonymously reachable, no auth/token needed or embedded. +_RELEASES_API_URL = ( + "https://git.avezzano.io/api/v1/repos/the_og/better-claude-config/releases/latest" +) + + +def parse_version(v: str) -> tuple[int, ...]: + """ + Parse a version string into a tuple of ints for numeric comparison. + + Strips a leading 'v' ("v1.2.3" -> "1.2.3") and any pre-release/build + metadata after a '-' or '+' ("1.2.3-beta.1" -> "1.2.3"). Stops at the + first non-numeric dotted component. Empty or entirely non-numeric input + returns an empty tuple rather than raising, so a malformed tag from a + flaky API response degrades gracefully instead of crashing the caller. + """ + s = (v or "").strip() + if s[:1].lower() == "v": + s = s[1:] + s = re.split(r"[-+]", s, maxsplit=1)[0] + parts: list[int] = [] + for chunk in s.split("."): + m = re.match(r"\d+", chunk) + if not m: + break + parts.append(int(m.group())) + return tuple(parts) + + +def is_newer_version(current: str, candidate: str) -> bool: + """ + True if `candidate` is a strictly newer version than `current`. + + Comparison is purely numeric (major.minor.patch, ...) — NEVER a lexical + string compare, so "v2.0.0" vs "v10.0.0" resolves correctly instead of + sorting "2" after "10". Tuples of differing length are zero-padded before + comparing, so "1.2" and "1.2.0" are correctly treated as equal. + + An unparseable `candidate` always yields False (nothing to report). An + unparseable `current` is treated as "0" for comparison purposes — a + malformed local version shouldn't silently suppress a real update. + """ + cur = parse_version(current) + new = parse_version(candidate) + if not new: + return False + width = max(len(cur), len(new), 1) + cur = cur + (0,) * (width - len(cur)) + new = new + (0,) * (width - len(new)) + return new > cur + + +def fetch_latest_release(timeout: float = 4.0) -> dict | None: + """ + Query the repo's (public, anonymous) Gitea releases API for the latest + release. Returns {"version": "", "url": ""} on + success, or None on ANY failure: network error, timeout, bad status, + malformed JSON, or a response missing tag_name. + + Fail-quiet by design — this is meant to be called off the UI thread + (see UpdateCheckWorker in bcc.py) for both the About dialog's "Check for + updates" button and an optional silent startup check. Never downloads or + touches any binary; this only ever reads release metadata. + """ + import urllib.error + import urllib.request + + req = urllib.request.Request( + _RELEASES_API_URL, + headers={ + "Accept": "application/json", + "User-Agent": f"BetterClaudeConfig/{__version__}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + payload = json.loads(r.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, ValueError, OSError): + return None + if not isinstance(payload, dict): + return None + tag = payload.get("tag_name") + if not tag or not isinstance(tag, str): + return None + return {"version": tag, "url": payload.get("html_url") or RELEASES_URL} + + # --------------------------------------------------------------------------- # # Data model # --------------------------------------------------------------------------- # diff --git a/tests/test_core.py b/tests/test_core.py index 1f28ca8..adbe807 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -4,6 +4,8 @@ proper test functions with tmp_path/monkeypatch fixtures).""" import json import os import sys +import urllib.error +import urllib.request import pytest @@ -910,3 +912,145 @@ def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch): result = c.restart_claude_desktop() assert not result.success assert "Claude Desktop" in result.detail + + +# --------------------------------------------------------------------------- # +# Version / update checking +# --------------------------------------------------------------------------- # +def test_dunder_version_matches_pyproject(): + # Guards against the version drifting out of sync between the two places + # a human might bump it. + assert c.__version__ == "1.1.0" + + +def test_parse_version_basic(): + assert c.parse_version("1.2.3") == (1, 2, 3) + + +def test_parse_version_strips_v_prefix(): + assert c.parse_version("v1.2.3") == (1, 2, 3) + + +def test_parse_version_strips_prerelease_suffix(): + assert c.parse_version("1.2.3-beta.1") == (1, 2, 3) + assert c.parse_version("1.2.3+build5") == (1, 2, 3) + + +def test_parse_version_malformed_returns_empty_tuple(): + assert c.parse_version("not-a-version") == () + assert c.parse_version("") == () + assert c.parse_version(None) == () + + +def test_parse_version_stops_at_first_non_numeric_component(): + assert c.parse_version("1.2.rc1") == (1, 2) + + +def test_is_newer_version_true_when_candidate_is_newer(): + assert c.is_newer_version("1.1.0", "1.2.0") is True + + +def test_is_newer_version_false_when_candidate_is_older(): + assert c.is_newer_version("1.2.0", "1.1.0") is False + + +def test_is_newer_version_false_when_equal(): + assert c.is_newer_version("1.1.0", "1.1.0") is False + + +def test_is_newer_version_equal_with_v_prefix(): + assert c.is_newer_version("1.1.0", "v1.1.0") is False + + +def test_is_newer_version_numeric_not_lexical(): + # A lexical/string compare would say "10.0.0" < "2.0.0" ("1" < "2"). + assert c.is_newer_version("2.0.0", "10.0.0") is True + assert c.is_newer_version("10.0.0", "2.0.0") is False + + +def test_is_newer_version_handles_differing_length(): + assert c.is_newer_version("1.2", "1.2.0") is False + assert c.is_newer_version("1.2.0", "1.2") is False + assert c.is_newer_version("1.2", "1.3") is True + + +def test_is_newer_version_malformed_candidate_is_never_newer(): + assert c.is_newer_version("1.1.0", "not-a-version") is False + assert c.is_newer_version("1.1.0", "") is False + + +def test_is_newer_version_malformed_current_does_not_crash(): + # Shouldn't raise; a valid candidate is reported as newer than "unknown". + assert c.is_newer_version("garbage", "1.0.0") is True + assert c.is_newer_version("garbage", "not-a-version-either") is False + + +class _FakeHTTPResponse: + def __init__(self, data: bytes): + self._data = data + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def test_fetch_latest_release_ok(monkeypatch): + payload = json.dumps( + { + "tag_name": "v1.2.0", + "html_url": "https://git.avezzano.io/the_og/better-claude-config/releases/tag/v1.2.0", + } + ).encode("utf-8") + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(payload) + ) + result = c.fetch_latest_release() + assert result == { + "version": "v1.2.0", + "url": "https://git.avezzano.io/the_og/better-claude-config/releases/tag/v1.2.0", + } + + +def test_fetch_latest_release_falls_back_to_releases_url_when_no_html_url(monkeypatch): + payload = json.dumps({"tag_name": "v1.2.0"}).encode("utf-8") + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(payload) + ) + result = c.fetch_latest_release() + assert result == {"version": "v1.2.0", "url": c.RELEASES_URL} + + +def test_fetch_latest_release_network_failure_returns_none(monkeypatch): + def boom(req, timeout=None): + raise urllib.error.URLError("no network") + + monkeypatch.setattr(urllib.request, "urlopen", boom) + assert c.fetch_latest_release() is None + + +def test_fetch_latest_release_timeout_returns_none(monkeypatch): + def boom(req, timeout=None): + raise TimeoutError("timed out") + + monkeypatch.setattr(urllib.request, "urlopen", boom) + assert c.fetch_latest_release() is None + + +def test_fetch_latest_release_missing_tag_returns_none(monkeypatch): + payload = json.dumps({"html_url": "https://example.com"}).encode("utf-8") + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(payload) + ) + assert c.fetch_latest_release() is None + + +def test_fetch_latest_release_malformed_json_returns_none(monkeypatch): + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(b"not json") + ) + assert c.fetch_latest_release() is None