feat: __version__ constant + notify-only update checker logic (#19), version source for About dialog (#18)
- Add `__version__ = "1.1.0"` as the single source of truth (matches pyproject.toml).
- Add parse_version()/is_newer_version() for numeric (never lexical) version
comparison, handling v-prefix, pre-release suffixes, and malformed input.
- Add fetch_latest_release(): reads the public Gitea releases API
(anonymous, no token) and returns {version, url} or None on any failure.
Never downloads or touches a binary — metadata only.
This commit is contained in:
+107
-1
@@ -46,6 +46,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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -474,7 +580,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str:
|
||||
out = text
|
||||
for junk in _JUNK_CHARS:
|
||||
out = out.replace(junk, "")
|
||||
out = out.replace(" ", " ") # non-breaking space
|
||||
out = out.replace(" ", " ") # non-breaking space
|
||||
for smart, ascii_q in _QUOTE_MAP.items():
|
||||
out = out.replace(smart, ascii_q)
|
||||
if out != text:
|
||||
|
||||
Reference in New Issue
Block a user