feat: src/launcher.py — Python update launcher (HTTP, app/app.old swap)
Launcher core that reuses services/update_service: seed default config into ~/.cmbot on first run, check the manifest, download the release zip, verify SHA-256, extract, validate version, swap into app/ (keeping app.old/ for rollback), then launch app/CMBot.exe. Degrades to the local version on any failure; checks install-root writability before updating. - update_service: UpdateInfo gains sha256/size/min_supported; release URL resolved to absolute; extract make_auth_header + add download() helper - tests/test_launcher.py: 11 tests (seed, find-root, swap, sha mismatch, no-update); also verified end-to-end against a local HTTP server Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -26,9 +26,12 @@ MANIFEST_NAME = "manifest.json"
|
||||
class UpdateInfo:
|
||||
"""A newer version advertised by the update source."""
|
||||
version: str
|
||||
source: str # folder or URL the user opens to get the new version
|
||||
source: str # absolute zip URL (http) or folder/path to the new version
|
||||
notes: str = ""
|
||||
mandatory: bool = False
|
||||
sha256: str = "" # expected hash of the release zip (launcher verifies)
|
||||
size: int = 0 # release zip size in bytes (0 = unknown)
|
||||
min_supported: str = ""
|
||||
|
||||
|
||||
def parse_version(text) -> tuple:
|
||||
@@ -70,17 +73,35 @@ def _manifest_url(update_source):
|
||||
return parse.urljoin(source, MANIFEST_NAME)
|
||||
|
||||
|
||||
def make_auth_header(update_user="", update_pass=""):
|
||||
"""Return an HTTP Basic Auth header dict, or {} when no credentials."""
|
||||
if not (update_user or update_pass):
|
||||
return {}
|
||||
raw = "{}:{}".format(update_user or "", update_pass or "").encode("utf-8")
|
||||
return {"Authorization": "Basic {}".format(base64.b64encode(raw).decode("ascii"))}
|
||||
|
||||
|
||||
def download(url, dest, update_user="", update_pass="", timeout=120):
|
||||
"""Download *url* to *dest* (Path/str) with optional Basic Auth.
|
||||
|
||||
Streams in chunks so large release zips don't load fully into memory.
|
||||
Raises (OSError / URLError) on failure — callers handle degradation.
|
||||
"""
|
||||
headers = {"User-Agent": "CMBot"}
|
||||
headers.update(make_auth_header(update_user, update_pass))
|
||||
req = request.Request(url, headers=headers)
|
||||
with request.urlopen(req, timeout=timeout) as response, open(str(dest), "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
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")
|
||||
)
|
||||
headers = {"Accept": "application/json", "User-Agent": "CMBot"}
|
||||
headers.update(make_auth_header(update_user, update_pass))
|
||||
|
||||
req = request.Request(url, headers=headers)
|
||||
with request.urlopen(req, timeout=5) as response:
|
||||
@@ -136,11 +157,18 @@ def check_for_update(
|
||||
source = str(data.get("url", "") or data.get("source", "")).strip()
|
||||
if not source:
|
||||
source = str(update_source)
|
||||
elif _is_http_source(update_source):
|
||||
# Resolve a relative release URL against the manifest URL so callers
|
||||
# (in-app banner / launcher) always get a directly usable absolute URL.
|
||||
source = parse.urljoin(_manifest_url(update_source), source)
|
||||
info = UpdateInfo(
|
||||
version=version,
|
||||
source=source,
|
||||
notes=str(data.get("notes", "")),
|
||||
mandatory=bool(data.get("mandatory", False)),
|
||||
sha256=str(data.get("sha256", "")).strip(),
|
||||
size=int(data.get("size", 0) or 0),
|
||||
min_supported=str(data.get("min_supported", "")).strip(),
|
||||
)
|
||||
logger.info("Update available: v%s (current v%s)", version, current_version)
|
||||
return info
|
||||
|
||||
Reference in New Issue
Block a user