release: v1.2.0 — About+update checker, log viewer, restart button, dup-name fix, stale-file hardening #26

Merged
the_og merged 34 commits from release/v1.2.0 into main 2026-07-08 11:34:35 -04:00
3 changed files with 458 additions and 2 deletions
Showing only changes of commit 15a30fb986 - Show all commits
+208 -2
View File
@@ -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
View File
@@ -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
# --------------------------------------------------------------------------- #
+144
View File
@@ -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