feat: support http update checks

This commit is contained in:
2026-06-18 09:38:44 +08:00
parent 6bc27f5180
commit 813e569639
3 changed files with 155 additions and 14 deletions
+7 -2
View File
@@ -149,15 +149,17 @@ class MainWindow(QMainWindow):
return bar
def _start_update_check(self):
"""Check the configured LAN source for a newer version, off the UI thread."""
"""Check the configured update source for a newer version, off the UI thread."""
source = self._config.get("update_source", "")
if not source:
return
user = self._config.get("update_user", "")
password = self._config.get("update_pass", "")
self._update_found.connect(self._on_update_found)
def worker():
try:
info = check_for_update(source, APP_VERSION)
info = check_for_update(source, APP_VERSION, user, password)
except Exception: # never let the thread crash startup
logger.exception("Update check failed")
return
@@ -191,6 +193,9 @@ class MainWindow(QMainWindow):
self._config.get("update_source", ""),
]
for path in candidates:
if path and path.lower().startswith(("http://", "https://")):
if QDesktopServices.openUrl(QUrl(path)):
return
if not path or not Path(path).exists():
continue
try:
+63 -12
View File
@@ -1,18 +1,21 @@
"""LAN update check (stage 2: notify-only).
"""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.
Reads a manifest.json from a configured local folder or HTTP(S) source 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 base64
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib import error, parse, request
logger = logging.getLogger(__name__)
@@ -23,7 +26,7 @@ MANIFEST_NAME = "manifest.json"
class UpdateInfo:
"""A newer version advertised by the update source."""
version: str
source: str # folder the user opens to get the new version
source: str # folder or URL the user opens to get the new version
notes: str = ""
mandatory: bool = False
@@ -53,7 +56,52 @@ def is_newer(remote, local) -> bool:
return parse_version(remote) > parse_version(local)
def check_for_update(update_source, current_version) -> Optional[UpdateInfo]:
def _is_http_source(update_source):
return parse.urlparse(str(update_source)).scheme.lower() in ("http", "https")
def _manifest_url(update_source):
source = str(update_source).strip()
parsed = parse.urlparse(source)
if Path(parsed.path).name.lower() == MANIFEST_NAME:
return source
if not source.endswith("/"):
source += "/"
return parse.urljoin(source, MANIFEST_NAME)
def _load_http_manifest(update_source, update_user="", update_pass=""):
url = _manifest_url(update_source)
headers = {
"Accept": "application/json",
"User-Agent": "CMBot",
}
if update_user or update_pass:
raw = "{}:{}".format(update_user or "", update_pass or "").encode("utf-8")
headers["Authorization"] = "Basic {}".format(
base64.b64encode(raw).decode("ascii")
)
req = request.Request(url, headers=headers)
with request.urlopen(req, timeout=5) as response:
payload = response.read()
if isinstance(payload, bytes):
payload = payload.decode("utf-8")
return json.loads(payload)
def _load_local_manifest(update_source):
manifest_path = Path(update_source) / MANIFEST_NAME
with open(str(manifest_path), encoding="utf-8") as f:
return json.load(f)
def check_for_update(
update_source,
current_version,
update_user="",
update_pass="",
) -> Optional[UpdateInfo]:
"""Return UpdateInfo if *update_source* advertises a version newer than
*current_version*, else None.
@@ -66,23 +114,26 @@ def check_for_update(update_source, current_version) -> Optional[UpdateInfo]:
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:
if _is_http_source(update_source):
data = _load_http_manifest(update_source, update_user, update_pass)
else:
data = _load_local_manifest(update_source)
except (OSError, ValueError, error.URLError) 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)
logger.warning("Manifest is not a JSON object, ignoring: %s", update_source)
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)
source = str(data.get("url", "") or data.get("source", "")).strip()
if not source:
source = str(update_source)
info = UpdateInfo(
version=version,
source=source,