diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..1b33a66 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -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": "", "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 # --------------------------------------------------------------------------- # @@ -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: