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
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 1.2.0 – 2026-10-07
### Added
- **Built-in updater**: predatord checks the release page every 6 hours (configurable), downloads the
new pacman package, verifies its SHA-256 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 in `/etc/predator-control/update-token` for private repositories
- `packaging/release.py` builds the package and publishes a release in the format the updater expects
## 1.1.0 – 2026-10-07
### Added
+12 -1
View File
@@ -48,7 +48,7 @@ Download `predator-control-<version>-any.pkg.tar.zst` from the
```bash
sudo pacman -S --needed linux-cachyos-headers clang lld llvm # headers for your kernel (stock Arch: linux-headers)
sudo pacman -U predator-control-1.1.0-1-any.pkg.tar.zst
sudo pacman -U predator-control-*-any.pkg.tar.zst
```
The service starts automatically and the kernel module is built by DKMS for every installed kernel.
@@ -83,6 +83,16 @@ Keyboard RGB and performance profiles work without the module.
Uninstall with `sudo pacman -R predator-control` (package) or `sudo ./uninstall.sh` (add `--purge` to also delete your settings).
## Updates
Installed via the pacman package, Predator Control updates itself: the service checks for new
releases every 6 hours, and a notice with an *Install* button appears on the **System** page, in the
sidebar and in the panel widget. Downloads are verified with the SHA-256 from the release notes and
installed with `pacman -U`. From the terminal: `predatorctl update` (or `--check`).
Automatic checks can be turned off on the System page.
If you installed with `install.sh`, update with `git pull && sudo ./install.sh`.
## Usage
### Panel widget
@@ -108,6 +118,7 @@ predatorctl kbd-brightness +25
predatorctl lightbar wave --color 00d4ff --speed 7
predatorctl lightbar --sync on # lightbar follows the keyboard
predatorctl battery-limit on
predatorctl update # check for and install updates
predatorctl open rgb # open the app on a page
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
# sudo ./install.sh --no-deps skip package installation (non-Arch distros)
set -euo pipefail
VERSION="1.1.0"
VERSION="1.2.0"
MODULE="predator-wmi"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PREFIX="/usr"
+1 -1
View File
@@ -1,5 +1,5 @@
PACKAGE_NAME="predator-wmi"
PACKAGE_VERSION="1.1.0"
PACKAGE_VERSION="1.2.0"
BUILT_MODULE_NAME[0]="predator_wmi"
DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86"
MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}"
+2 -2
View File
@@ -5,7 +5,7 @@
# sudo pacman -U predator-control-*.pkg.tar.zst
pkgname=predator-control
pkgver=1.1.0
pkgver=1.2.0
pkgrel=1
pkgdesc="PredatorSense replacement for Acer Predator laptops: performance modes, fans, keyboard RGB, lightbar, battery (Qt app + Plasma widget)"
arch=('any')
@@ -22,7 +22,7 @@ install=predator-control.install
source=()
_src="$(realpath "$startdir/../..")"
_modver=1.1.0
_modver=1.2.0
package() {
cd "$_src"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Build the pacman package and publish a Gitea release for the current version.
Usage: GITEA_TOKEN=... packaging/release.py
Requires a clean working tree and the tag v<version> already pushed.
The release notes are taken from CHANGELOG.md and contain the package's SHA-256,
which the built-in updater uses to verify downloads.
"""
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
API = "https://git.mrblake.cc/api/v1/repos/MrBlake/Cachy-Predator-Keyboard"
def api(method, path, data=None, ctype="application/json"):
headers = {"Authorization": "token " + os.environ["GITEA_TOKEN"]}
if data is not None:
headers["Content-Type"] = ctype
req = urllib.request.Request(API + path, data=data, method=method, headers=headers)
return json.load(urllib.request.urlopen(req))
def main():
if "GITEA_TOKEN" not in os.environ:
sys.exit("set GITEA_TOKEN")
version = re.search(r'__version__ = "([^"]+)"', open(os.path.join(ROOT, "predator_control/__init__.py")).read())[1]
pkgbuild = open(os.path.join(ROOT, "packaging/arch/PKGBUILD")).read()
if "pkgver=%s\n" % version not in pkgbuild:
sys.exit("PKGBUILD pkgver does not match version %s" % version)
pkgrel = re.search(r"^pkgrel=(\d+)", pkgbuild, re.M)[1]
arch_dir = os.path.join(ROOT, "packaging/arch")
subprocess.run(["makepkg", "-f", "--cleanbuild"], cwd=arch_dir, check=True)
pkg = "predator-control-%s-%s-any.pkg.tar.zst" % (version, pkgrel)
data = open(os.path.join(arch_dir, pkg), "rb").read()
sha = hashlib.sha256(data).hexdigest()
changelog = open(os.path.join(ROOT, "CHANGELOG.md")).read()
notes = changelog.split("## %s" % version, 1)[1].split("\n## ", 1)[0].split("\n", 1)[1].strip()
body = notes + f"""
### Install (Arch / CachyOS / Manjaro)
```bash
sudo pacman -S --needed linux-cachyos-headers clang lld llvm # stock Arch kernel: linux-headers
sudo pacman -U {pkg}
```
Already installed via pacman? Use the built-in updater (System page, panel widget or `predatorctl update`).
SHA-256 `{pkg}`: `{sha}`
Not affiliated with Acer Inc."""
rel = api("POST", "/releases", json.dumps({
"tag_name": "v" + version, "name": "Predator Control " + version,
"body": body, "draft": False, "prerelease": False}).encode())
boundary = "----predator" + sha[:16]
mp = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{pkg}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n").encode() + data + f"\r\n--{boundary}--\r\n".encode()
api("POST", "/releases/%d/assets?name=%s" % (rel["id"], pkg), mp, "multipart/form-data; boundary=" + boundary)
print("Released", rel["html_url"])
print("SHA-256", sha)
if __name__ == "__main__":
main()
@@ -72,6 +72,7 @@ PlasmoidItem {
? profileLabel(st.profile) + " · CPU " + temp(st.sensors.cpu_temp) + " · GPU " + temp(st.sensors.gpu_temp)
+ "\nFans " + (st.sensors.cpu_fan || 0) + " / " + (st.sensors.gpu_fan || 0) + " RPM"
+ "\nBattery " + batteryText()
+ (st.update && st.update.available ? "\nUpdate " + st.update.latest + " available" : "")
: "Service predatord not running"
P5Support.DataSource {
@@ -352,6 +353,27 @@ PlasmoidItem {
}
}
Kirigami.InlineMessage {
Layout.fillWidth: true
visible: root.ok && root.st.update !== null && root.st.update !== undefined && root.st.update.available
type: Kirigami.MessageType.Positive
text: root.ok && root.st.update ? (root.st.update_status === "running"
? "Installing update " + root.st.update.latest + "…"
: "Update " + root.st.update.latest + " available") : ""
actions: [
Kirigami.Action {
text: "Install"
icon.name: "update-none"
visible: root.ok && root.st.update && root.st.update.can_install && root.st.update_status !== "running"
onTriggered: root.run("update --yes")
},
Kirigami.Action {
text: "Details"
onTriggered: root.openPage("system")
}
]
}
PC3.Label {
Layout.fillWidth: true
visible: root.lastError !== ""
@@ -12,7 +12,7 @@
"Id": "org.predatorcontrol.panel",
"License": "GPL-2.0-only",
"Name": "Predator Control",
"Version": "1.1.0",
"Version": "1.2.0",
"Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
},
"X-Plasma-API-Minimum-Version": "6.0",
+1 -1
View File
@@ -2,7 +2,7 @@
import os
__version__ = "1.1.0"
__version__ = "1.2.0"
APP_NAME = "Predator Control"
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
+19
View File
@@ -61,6 +61,10 @@ def main(argv=None):
sp = sub.add_parser("battery-limit", help="80 %% charge limit")
sp.add_argument("value", choices=["on", "off"])
sp = sub.add_parser("update", help="check for and install updates")
sp.add_argument("--check", action="store_true", help="only check, do not install")
sp.add_argument("--yes", "-y", action="store_true", help="install without asking")
sp = sub.add_parser("open", help="open the main window")
sp.add_argument("page", nargs="?", default="home")
@@ -139,6 +143,21 @@ def main(argv=None):
if a.sync:
args["sync_keyboard"] = a.sync == "on"
c.call("set_lightbar", **args)
elif a.cmd == "update":
u = c.call("check_update")
if not u["available"]:
print("Predator Control %s is up to date." % u["current"])
return 0
print("Update available: %s -> %s\n%s" % (u["current"], u["latest"], u["url"]))
if a.check:
return 0
if not u["can_install"]:
print("Automatic install needs the pacman package. Update with: git pull && sudo ./install.sh")
return 1
if not a.yes and sys.stdin.isatty() and input("Install now? [Y/n] ").strip().lower() not in ("", "y", "yes"):
return 0
c.call("install_update")
print("Installing %s in the background (journalctl -fu predator-control-update)." % u["asset"])
elif a.cmd == "battery-limit":
c.call("set_battery_limit", enabled=_bool(a.value))
except DaemonError as e:
+49
View File
@@ -24,6 +24,7 @@ import traceback
from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__
from . import hardware as hw
from . import updater
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
DEFAULT_CONFIG = {
@@ -47,6 +48,7 @@ DEFAULT_CONFIG = {
},
"battery": {"limit": None}, # None = leave the firmware state alone
"keyboard_presets": {},
"updates": {"auto_check": True, "interval_hours": 6},
}
@@ -93,6 +95,9 @@ class Daemon:
self.state = {}
self.battery_limit = None
self._ticks = 0
self.update_info = None
self.update_error = None
self._update_checking = False
# ---------------------------------------------------------- Configuration
def _load_config(self):
@@ -230,6 +235,9 @@ class Daemon:
"lightbar_effective": self.lightbar_effective(),
"fan_curve_applied": self.curve_applied,
"battery_limit": self.battery_limit,
"update": self.update_info,
"update_error": self.update_error,
"update_status": updater.status(),
"available": {
"keyboard": self.kbd_present,
"wmi": hw.wmi_available(hw.GUID_GAMING),
@@ -241,6 +249,34 @@ class Daemon:
}
return st
def check_update_async(self):
if self._update_checking:
return
self._update_checking = True
def work():
try:
info = updater.check()
if info["available"] and (self.update_info or {}).get("latest") != info["latest"]:
log("Update available: %s -> %s" % (info["current"], info["latest"]))
self.update_info, self.update_error = info, None
except Exception as e:
self.update_error = str(e)
finally:
self._update_checking = False
threading.Thread(target=work, daemon=True).start()
def maybe_check_update(self):
u = self.config.get("updates", {})
if not u.get("auto_check", True):
return
last = (self.update_info or {}).get("checked", 0)
if time.time() - last > float(u.get("interval_hours", 6)) * 3600:
if self.update_error and time.time() - getattr(self, "_last_try", 0) < 900:
return # offline: retry every 15 min
self._last_try = time.time()
self.check_update_async()
def loop(self):
while True:
try:
@@ -261,6 +297,8 @@ class Daemon:
self.last_ac = ac
self.fan_curve_tick()
self._ticks += 1
if self._ticks % 20 == 1:
self.maybe_check_update()
if self._ticks % 40 == 0:
self.refresh_battery_limit()
self.state = self.build_state()
@@ -279,6 +317,17 @@ class Daemon:
if cmd == "state":
self.state = self.build_state()
return self.state
if cmd == "check_update":
self.update_info, self.update_error = updater.check(), None
return self.update_info
if cmd == "install_update":
info = updater.check()
self.update_info = info
return updater.install(info)
if cmd == "set_updates":
merge(self.config["updates"], {k: a[k] for k in ("auto_check", "interval_hours") if k in a})
self.save_config()
return {}
if cmd == "restore":
self.restore()
return {}
+63 -2
View File
@@ -4,9 +4,10 @@ import argparse
import os
import sys
import threading
import time
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QIcon
from PySide6.QtGui import QColor, QIcon, QPalette
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget,
@@ -673,6 +674,29 @@ class SystemPage(Page):
self.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
self.lay.addWidget(self.fw_card)
upd = Card("Updates")
self.upd_head = QLabel("")
self.upd_head.setObjectName("big")
upd.body.addWidget(self.upd_head)
self.upd_text = _muted("")
self.upd_text.setTextFormat(Qt.MarkdownText)
self.upd_text.setOpenExternalLinks(True)
upd.body.addWidget(self.upd_text)
h = QHBoxLayout()
self.upd_check = QPushButton("Check now")
self.upd_check.clicked.connect(lambda: (self.upd_check.setEnabled(False), win.backend.run("check_update")))
h.addWidget(self.upd_check)
self.upd_install = QPushButton("Install update")
self.upd_install.setObjectName("primary")
self.upd_install.clicked.connect(self._install)
h.addWidget(self.upd_install)
self.upd_auto = QCheckBox("Check for updates automatically")
self.upd_auto.clicked.connect(lambda v: win.backend.run("set_updates", auto_check=bool(v)))
h.addWidget(self.upd_auto)
h.addStretch(1)
upd.body.addLayout(h)
self.lay.addWidget(upd)
svc = Card("Status")
self.status = _muted("")
svc.body.addWidget(self.status)
@@ -728,7 +752,37 @@ class SystemPage(Page):
box.setChecked(bool(info.get("value")))
box.setToolTip(info.get("error", "") or "")
def _install(self):
u = self.win.state.get("update") or {}
if QMessageBox.question(self, "Install update",
"Install Predator Control %s now?\n\nThe background service restarts briefly; "
"reopen the app afterwards." % u.get("latest")) == QMessageBox.Yes:
self.upd_install.setEnabled(False)
self.win.backend.run("install_update")
def update_state(self, st):
u = st.get("update") or {}
status = st.get("update_status")
self.upd_check.setEnabled(True)
if status == "running":
self.upd_head.setText("Installing update…")
self.upd_text.setText("pacman is installing the new version.")
self.upd_install.hide()
elif u.get("available"):
self.upd_head.setText("Version %s available" % u["latest"])
text = "Installed: %s · [Release page](%s)\n\n%s" % (u["current"], u.get("url"), u.get("notes", "").split("### Install")[0].strip())
if not u.get("can_install"):
text += "\n\n*Automatic install needs the pacman package – otherwise run `git pull && sudo ./install.sh`.*"
self.upd_text.setText(text)
self.upd_install.setVisible(bool(u.get("can_install")))
else:
self.upd_head.setText("Up to date" if u else "Not checked yet")
when = time.strftime("%d.%m.%Y %H:%M", time.localtime(u["checked"])) if u.get("checked") else "–"
extra = "\n\nLast update failed – see: journalctl -u predator-control-update" if status == "failed" else ""
err = ("\n\n⚠ %s" % st["update_error"]) if st.get("update_error") else ""
self.upd_text.setText("Installed version %s · last check %s%s%s" % (st.get("version"), when, err, extra))
self.upd_install.hide()
self.upd_auto.setChecked(st["config"].get("updates", {}).get("auto_check", True))
a = st["available"]
yes = lambda v: "✔" if v else "✘"
lines = ["%s Service predatord %s" % (yes(True), st.get("version")),
@@ -833,13 +887,17 @@ class MainWindow(QMainWindow):
if self.stack.currentWidget() is not bat_page:
bat_page.indicator.set_battery(st["battery"]) # keep the power history going
s = st["sensors"]
self.footer.setText("%s\nCPU %s °C · GPU %s" % (
u = st.get("update") or {}
badge = ("⬆ Update %s available\n" % u["latest"]) if u.get("available") else ""
self.footer.setText(badge + "%s\nCPU %s °C · GPU %s" % (
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
def _done(self, cmd, result, err):
if err:
self.statusBar().showMessage("Error: %s" % err, 8000)
elif cmd == "install_update":
self.statusBar().showMessage("Update is being installed…", 8000)
elif cmd == "get_firmware":
self.pages["system"][0].firmware(result)
self.pages["battery"][0].firmware(result)
@@ -860,6 +918,9 @@ def main(argv=None):
app.setDesktopFileName("predator-control")
app.setStyle("Fusion")
app.setStyleSheet(theme.QSS)
pal = app.palette()
pal.setColor(QPalette.Link, QColor(theme.ACCENT))
app.setPalette(pal)
# single instance: forward the requested page to a running window
name = "predator-control-%d" % os.getuid()
+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"