feat: support http update checks
This commit is contained in:
@@ -149,15 +149,17 @@ class MainWindow(QMainWindow):
|
|||||||
return bar
|
return bar
|
||||||
|
|
||||||
def _start_update_check(self):
|
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", "")
|
source = self._config.get("update_source", "")
|
||||||
if not source:
|
if not source:
|
||||||
return
|
return
|
||||||
|
user = self._config.get("update_user", "")
|
||||||
|
password = self._config.get("update_pass", "")
|
||||||
self._update_found.connect(self._on_update_found)
|
self._update_found.connect(self._on_update_found)
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
try:
|
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
|
except Exception: # never let the thread crash startup
|
||||||
logger.exception("Update check failed")
|
logger.exception("Update check failed")
|
||||||
return
|
return
|
||||||
@@ -191,6 +193,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._config.get("update_source", ""),
|
self._config.get("update_source", ""),
|
||||||
]
|
]
|
||||||
for path in candidates:
|
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():
|
if not path or not Path(path).exists():
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
Reads a manifest.json from a configured local folder or HTTP(S) source and
|
||||||
version is advertised. This module performs no installation — it only decides
|
reports whether a newer version is advertised. This module performs no
|
||||||
whether to notify the user. See docs/10-lan-update.md.
|
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
|
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
|
manifest yields "no update" rather than an error, so an update check can never
|
||||||
block or break startup.
|
block or break startup.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from urllib import error, parse, request
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ MANIFEST_NAME = "manifest.json"
|
|||||||
class UpdateInfo:
|
class UpdateInfo:
|
||||||
"""A newer version advertised by the update source."""
|
"""A newer version advertised by the update source."""
|
||||||
version: str
|
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 = ""
|
notes: str = ""
|
||||||
mandatory: bool = False
|
mandatory: bool = False
|
||||||
|
|
||||||
@@ -53,7 +56,52 @@ def is_newer(remote, local) -> bool:
|
|||||||
return parse_version(remote) > parse_version(local)
|
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
|
"""Return UpdateInfo if *update_source* advertises a version newer than
|
||||||
*current_version*, else None.
|
*current_version*, else None.
|
||||||
|
|
||||||
@@ -66,23 +114,26 @@ def check_for_update(update_source, current_version) -> Optional[UpdateInfo]:
|
|||||||
if not update_source:
|
if not update_source:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
manifest_path = Path(update_source) / MANIFEST_NAME
|
|
||||||
try:
|
try:
|
||||||
with open(str(manifest_path), encoding="utf-8") as f:
|
if _is_http_source(update_source):
|
||||||
data = json.load(f)
|
data = _load_http_manifest(update_source, update_user, update_pass)
|
||||||
except (OSError, ValueError) as exc:
|
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)
|
logger.info("Update check skipped (%s): %s", type(exc).__name__, exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
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
|
return None
|
||||||
|
|
||||||
version = str(data.get("version", "")).strip()
|
version = str(data.get("version", "")).strip()
|
||||||
if not version or not is_newer(version, current_version):
|
if not version or not is_newer(version, current_version):
|
||||||
return None
|
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(
|
info = UpdateInfo(
|
||||||
version=version,
|
version=version,
|
||||||
source=source,
|
source=source,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
"""Tests for services.update_service — no GUI dependency."""
|
"""Tests for services.update_service — no GUI dependency."""
|
||||||
import json
|
import json
|
||||||
|
import base64
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib import error
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
@@ -100,6 +103,88 @@ class TestCheckForUpdate(unittest.TestCase):
|
|||||||
info = check_for_update(str(self.tmp), "1.0.0")
|
info = check_for_update(str(self.tmp), "1.0.0")
|
||||||
self.assertEqual(info.source, str(self.tmp))
|
self.assertEqual(info.source, str(self.tmp))
|
||||||
|
|
||||||
|
def test_http_source_appends_manifest_name(self):
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=0):
|
||||||
|
calls["url"] = req.full_url
|
||||||
|
return _FakeResponse({
|
||||||
|
"version": "1.2.0",
|
||||||
|
"url": "https://example.test/CMBot-1.2.0.zip",
|
||||||
|
"notes": "http update",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch("services.update_service.request.urlopen", fake_urlopen):
|
||||||
|
info = check_for_update("https://example.test/releases", "1.0.0")
|
||||||
|
|
||||||
|
self.assertIsInstance(info, UpdateInfo)
|
||||||
|
self.assertEqual(calls["url"], "https://example.test/releases/manifest.json")
|
||||||
|
self.assertEqual(info.source, "https://example.test/CMBot-1.2.0.zip")
|
||||||
|
|
||||||
|
def test_http_source_accepts_full_manifest_url(self):
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=0):
|
||||||
|
calls["url"] = req.full_url
|
||||||
|
return _FakeResponse({"version": "1.2.0"})
|
||||||
|
|
||||||
|
manifest_url = "https://example.test/releases/manifest.json"
|
||||||
|
with patch("services.update_service.request.urlopen", fake_urlopen):
|
||||||
|
info = check_for_update(manifest_url, "1.0.0")
|
||||||
|
|
||||||
|
self.assertIsInstance(info, UpdateInfo)
|
||||||
|
self.assertEqual(calls["url"], manifest_url)
|
||||||
|
self.assertEqual(info.source, manifest_url)
|
||||||
|
|
||||||
|
def test_http_basic_auth_header(self):
|
||||||
|
calls = {}
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=0):
|
||||||
|
calls["auth"] = req.get_header("Authorization")
|
||||||
|
return _FakeResponse({"version": "2.0.0"})
|
||||||
|
|
||||||
|
with patch("services.update_service.request.urlopen", fake_urlopen):
|
||||||
|
check_for_update(
|
||||||
|
"https://example.test",
|
||||||
|
"1.0.0",
|
||||||
|
update_user="readonly",
|
||||||
|
update_pass="secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = base64.b64encode(b"readonly:secret").decode("ascii")
|
||||||
|
self.assertEqual(calls["auth"], "Basic {}".format(token))
|
||||||
|
|
||||||
|
def test_http_error_returns_none(self):
|
||||||
|
def fake_urlopen(req, timeout=0):
|
||||||
|
raise error.URLError("offline")
|
||||||
|
|
||||||
|
with patch("services.update_service.request.urlopen", fake_urlopen):
|
||||||
|
self.assertIsNone(check_for_update("https://example.test", "1.0.0"))
|
||||||
|
|
||||||
|
def test_http_malformed_json_returns_none(self):
|
||||||
|
def fake_urlopen(req, timeout=0):
|
||||||
|
return _FakeResponse("{ not valid json", raw=True)
|
||||||
|
|
||||||
|
with patch("services.update_service.request.urlopen", fake_urlopen):
|
||||||
|
self.assertIsNone(check_for_update("https://example.test", "1.0.0"))
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, data, raw=False):
|
||||||
|
if raw:
|
||||||
|
self._payload = data.encode("utf-8")
|
||||||
|
else:
|
||||||
|
self._payload = json.dumps(data).encode("utf-8")
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user