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:
2026-06-17 17:05:46 +08:00
co-authored by Claude Opus 4.8
parent f2b08fecb3
commit 0392e1c8a2
8 changed files with 373 additions and 9 deletions
+1
View File
@@ -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"
+93
View File
@@ -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