Merge branch 'feat/18-19-about-update-checker' into integration/v1.2.0
# Conflicts: # bcc.py # bcc_core.py # tests/test_core.py
This commit is contained in:
@@ -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):
|
||||
|
||||
+106
@@ -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": "<tag>", "url": "<releases page>"} 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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user