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"}
|
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
|
# Data model
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -474,7 +580,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str:
|
|||||||
out = text
|
out = text
|
||||||
for junk in _JUNK_CHARS:
|
for junk in _JUNK_CHARS:
|
||||||
out = out.replace(junk, "")
|
out = out.replace(junk, "")
|
||||||
out = out.replace(" ", " ") # non-breaking space
|
out = out.replace(" ", " ") # non-breaking space
|
||||||
for smart, ascii_q in _QUOTE_MAP.items():
|
for smart, ascii_q in _QUOTE_MAP.items():
|
||||||
out = out.replace(smart, ascii_q)
|
out = out.replace(smart, ascii_q)
|
||||||
if out != text:
|
if out != text:
|
||||||
|
|||||||
Reference in New Issue
Block a user