Add built-in updater, bump to 1.2.0

- predatord checks the Gitea releases every 6 hours, downloads the
  newest pacman package, verifies its SHA-256 from the release notes
  and installs it with `pacman -U` in a separate systemd unit
- Update notice and install button on the System page, in the sidebar
  and in the panel widget; `predatorctl update [--check] [--yes]`
- Optional API token for private repositories
- packaging/release.py builds and publishes releases in the format
  the updater expects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 17:42:02 +02:00
co-authored by Claude Opus 5.5
parent 2ef62e85cd
commit cc4fdb9cd0
14 changed files with 376 additions and 9 deletions
+123
View File
@@ -0,0 +1,123 @@
"""Self-updater: checks the Gitea releases for a newer pacman package and installs it.
The release must carry a `predator-control-<ver>-<rel>-any.pkg.tar.zst` asset and its
SHA-256 in the release notes. Downloads are verified before `pacman -U` runs.
An optional API token (for private repositories) is read from
/etc/predator-control/update-token.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import time
import urllib.request
from . import CONFIG_DIR, __version__
API = "https://git.mrblake.cc/api/v1/repos/MrBlake/Cachy-Predator-Keyboard"
CACHE_DIR = "/var/cache/predator-control"
TOKEN_PATH = CONFIG_DIR + "/update-token"
PKG_RE = re.compile(r"^predator-control-[\w.+]+-\d+-any\.pkg\.tar\.zst$")
UNIT = "predator-control-update"
class UpdateError(RuntimeError):
pass
def parse_version(v):
return tuple(int(x) for x in re.findall(r"\d+", v.lstrip("v"))[:3])
def _request(url, binary=False):
headers = {"Accept": "application/json", "User-Agent": "predator-control/" + __version__}
try:
with open(TOKEN_PATH) as f:
token = f.read().strip()
if token:
headers["Authorization"] = "token " + token
except OSError:
pass
try:
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as r:
data = r.read()
except OSError as e:
raise UpdateError("cannot reach the update server: %s" % e) from e
return data if binary else json.loads(data)
def installed_as_package():
"""True if the app was installed through pacman (only then can it update itself)."""
if not shutil.which("pacman"):
return False
return subprocess.run(["pacman", "-Qq", "predator-control"], capture_output=True).returncode == 0
def check():
"""Return information about the latest release."""
releases = [r for r in _request(API + "/releases?limit=50")
if not r.get("draft") and not r.get("prerelease") and parse_version(r.get("tag_name", ""))]
if not releases:
raise UpdateError("no releases found")
rel = max(releases, key=lambda r: parse_version(r["tag_name"]))
latest = rel.get("tag_name", "").lstrip("v")
asset = next((a for a in rel.get("assets", []) if PKG_RE.match(a.get("name", ""))), None)
sha = None
if asset:
m = re.search(re.escape(asset["name"]) + r"\W+([0-9a-f]{64})", rel.get("body", ""))
sha = m.group(1) if m else None
return {
"current": __version__,
"latest": latest,
"available": parse_version(latest) > parse_version(__version__),
"notes": rel.get("body", ""),
"url": rel.get("html_url"),
"asset": asset["name"] if asset else None,
"asset_url": asset["browser_download_url"] if asset else None,
"sha256": sha,
"can_install": installed_as_package() and bool(asset and sha),
"checked": time.time(),
}
def install(info):
"""Download, verify and install the package in a separate systemd unit.
pacman's post_upgrade restarts predatord, so the installation must not run
inside the daemon's own process.
"""
if not info.get("available"):
raise UpdateError("already up to date")
if not installed_as_package():
raise UpdateError("not installed via pacman – update with git pull && sudo ./install.sh")
if not info.get("asset_url") or not info.get("sha256"):
raise UpdateError("release has no verifiable package")
os.makedirs(CACHE_DIR, exist_ok=True)
path = os.path.join(CACHE_DIR, info["asset"])
data = _request(info["asset_url"], binary=True)
digest = hashlib.sha256(data).hexdigest()
if digest != info["sha256"]:
raise UpdateError("checksum mismatch – download rejected")
with open(path, "wb") as f:
f.write(data)
subprocess.run(["systemctl", "reset-failed", UNIT + ".service"], capture_output=True)
r = subprocess.run(["systemd-run", "--unit=" + UNIT, "--description=Predator Control update",
"pacman", "-U", "--noconfirm", "--needed", path],
capture_output=True, text=True)
if r.returncode != 0:
raise UpdateError("could not start the update: %s" % r.stderr.strip())
return {"started": True, "package": info["asset"]}
def status():
"""State of the installer unit: 'running', 'failed' or 'idle'."""
r = subprocess.run(["systemctl", "is-active", UNIT + ".service"], capture_output=True, text=True)
s = r.stdout.strip()
if s in ("active", "activating"):
return "running"
if s == "failed":
return "failed"
return "idle"