diff --git a/CHANGELOG.md b/CHANGELOG.md index d612a81..13c23dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c2c71a4..f46d869 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Download `predator-control--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 ``` diff --git a/docs/screenshots/system.png b/docs/screenshots/system.png new file mode 100644 index 0000000..e9f50d6 Binary files /dev/null and b/docs/screenshots/system.png differ diff --git a/install.sh b/install.sh index c5ca0ae..ab31bdf 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/kernel/dkms.conf b/kernel/dkms.conf index 820f3a8..439c1d0 100644 --- a/kernel/dkms.conf +++ b/kernel/dkms.conf @@ -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}" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index f799951..9cbe9fa 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -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" diff --git a/packaging/release.py b/packaging/release.py new file mode 100755 index 0000000..7d3b9c1 --- /dev/null +++ b/packaging/release.py @@ -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 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() diff --git a/plasmoid/org.predatorcontrol.panel/contents/ui/main.qml b/plasmoid/org.predatorcontrol.panel/contents/ui/main.qml index e2f98b2..417ae62 100644 --- a/plasmoid/org.predatorcontrol.panel/contents/ui/main.qml +++ b/plasmoid/org.predatorcontrol.panel/contents/ui/main.qml @@ -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 !== "" diff --git a/plasmoid/org.predatorcontrol.panel/metadata.json b/plasmoid/org.predatorcontrol.panel/metadata.json index dd26946..bb0e1a0 100644 --- a/plasmoid/org.predatorcontrol.panel/metadata.json +++ b/plasmoid/org.predatorcontrol.panel/metadata.json @@ -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", diff --git a/predator_control/__init__.py b/predator_control/__init__.py index 012ec92..573d4b3 100644 --- a/predator_control/__init__.py +++ b/predator_control/__init__.py @@ -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") diff --git a/predator_control/cli.py b/predator_control/cli.py index e68519e..3ddfc2c 100644 --- a/predator_control/cli.py +++ b/predator_control/cli.py @@ -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: diff --git a/predator_control/daemon.py b/predator_control/daemon.py index c0390bd..ff4cb71 100644 --- a/predator_control/daemon.py +++ b/predator_control/daemon.py @@ -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 {} diff --git a/predator_control/gui/app.py b/predator_control/gui/app.py index a95e132..df79a02 100644 --- a/predator_control/gui/app.py +++ b/predator_control/gui/app.py @@ -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() diff --git a/predator_control/updater.py b/predator_control/updater.py new file mode 100644 index 0000000..916bfd9 --- /dev/null +++ b/predator_control/updater.py @@ -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---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"