feat: LAN update check on startup with notify banner (stage 2)
Read manifest.json from the configured update_source and show a dismissable banner when a newer version is available. Notify-only — no install yet. - services/update_service.py: version compare + check_for_update (pure, tested) - config: add update_source key (empty = no check) - main_window: top banner, background-thread check; open folder via os.startfile (QDesktopServices.openUrl mishandles file:// folder URLs — ShellExecute err 2) - tests: +15 covering version compare and check_for_update branches - docs 02/05/10 + tasks 17.16 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ DEFAULT_CONFIG = {
|
||||
"last_print_dir": "",
|
||||
"last_template": "", # name of the last-selected template
|
||||
"last_batch_mode": "full_combo", # BatchMode value of the last-used mode
|
||||
"update_source": "", # LAN folder holding manifest.json (empty = no update check)
|
||||
}
|
||||
|
||||
_CONFIG_FILENAME = "app_config.json"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""LAN update check (stage 2: notify-only).
|
||||
|
||||
Reads a manifest.json from a configured LAN folder and reports whether a newer
|
||||
version is advertised. This module performs no installation — it only decides
|
||||
whether to notify the user. See docs/10-lan-update.md.
|
||||
|
||||
All functions degrade gracefully: a missing/unreachable source or a malformed
|
||||
manifest yields "no update" rather than an error, so an update check can never
|
||||
block or break startup.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateInfo:
|
||||
"""A newer version advertised by the update source."""
|
||||
version: str
|
||||
source: str # folder the user opens to get the new version
|
||||
notes: str = ""
|
||||
mandatory: bool = False
|
||||
|
||||
|
||||
def parse_version(text) -> tuple:
|
||||
"""Parse 'a.b.c' into a comparable (a, b, c) int tuple.
|
||||
|
||||
Tolerant: missing parts pad with 0, non-numeric suffixes (e.g. '1rc2')
|
||||
keep their leading digits, fully non-numeric parts become 0.
|
||||
"""
|
||||
nums = []
|
||||
for part in str(text).strip().split(".")[:3]:
|
||||
digits = ""
|
||||
for ch in part:
|
||||
if ch.isdigit():
|
||||
digits += ch
|
||||
else:
|
||||
break
|
||||
nums.append(int(digits) if digits else 0)
|
||||
while len(nums) < 3:
|
||||
nums.append(0)
|
||||
return tuple(nums)
|
||||
|
||||
|
||||
def is_newer(remote, local) -> bool:
|
||||
"""True if version string *remote* is strictly newer than *local*."""
|
||||
return parse_version(remote) > parse_version(local)
|
||||
|
||||
|
||||
def check_for_update(update_source, current_version) -> Optional[UpdateInfo]:
|
||||
"""Return UpdateInfo if *update_source* advertises a version newer than
|
||||
*current_version*, else None.
|
||||
|
||||
Returns None (never raises) when:
|
||||
- no source is configured,
|
||||
- the source/manifest is unreachable or unreadable,
|
||||
- the manifest is malformed,
|
||||
- the advertised version is not newer.
|
||||
"""
|
||||
if not update_source:
|
||||
return None
|
||||
|
||||
manifest_path = Path(update_source) / MANIFEST_NAME
|
||||
try:
|
||||
with open(str(manifest_path), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.info("Update check skipped (%s): %s", type(exc).__name__, exc)
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Manifest is not a JSON object, ignoring: %s", manifest_path)
|
||||
return None
|
||||
|
||||
version = str(data.get("version", "")).strip()
|
||||
if not version or not is_newer(version, current_version):
|
||||
return None
|
||||
|
||||
source = str(data.get("source", "")).strip() or str(update_source)
|
||||
info = UpdateInfo(
|
||||
version=version,
|
||||
source=source,
|
||||
notes=str(data.get("notes", "")),
|
||||
mandatory=bool(data.get("mandatory", False)),
|
||||
)
|
||||
logger.info("Update available: v%s (current v%s)", version, current_version)
|
||||
return info
|
||||
Reference in New Issue
Block a user