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:
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# 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
|
## 1.1.0 – 2026-10-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Download `predator-control-<version>-any.pkg.tar.zst` from the
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -S --needed linux-cachyos-headers clang lld llvm # headers for your kernel (stock Arch: linux-headers)
|
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.
|
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).
|
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
|
## Usage
|
||||||
|
|
||||||
### Panel widget
|
### Panel widget
|
||||||
@@ -108,6 +118,7 @@ predatorctl kbd-brightness +25
|
|||||||
predatorctl lightbar wave --color 00d4ff --speed 7
|
predatorctl lightbar wave --color 00d4ff --speed 7
|
||||||
predatorctl lightbar --sync on # lightbar follows the keyboard
|
predatorctl lightbar --sync on # lightbar follows the keyboard
|
||||||
predatorctl battery-limit on
|
predatorctl battery-limit on
|
||||||
|
predatorctl update # check for and install updates
|
||||||
predatorctl open rgb # open the app on a page
|
predatorctl open rgb # open the app on a page
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# sudo ./install.sh --no-deps skip package installation (non-Arch distros)
|
# sudo ./install.sh --no-deps skip package installation (non-Arch distros)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="1.1.0"
|
VERSION="1.2.0"
|
||||||
MODULE="predator-wmi"
|
MODULE="predator-wmi"
|
||||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PREFIX="/usr"
|
PREFIX="/usr"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
PACKAGE_NAME="predator-wmi"
|
PACKAGE_NAME="predator-wmi"
|
||||||
PACKAGE_VERSION="1.1.0"
|
PACKAGE_VERSION="1.2.0"
|
||||||
BUILT_MODULE_NAME[0]="predator_wmi"
|
BUILT_MODULE_NAME[0]="predator_wmi"
|
||||||
DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86"
|
DEST_MODULE_LOCATION[0]="/kernel/drivers/platform/x86"
|
||||||
MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}"
|
MAKE[0]="make KVER=${kernelver} KDIR=${kernel_source_dir}"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# sudo pacman -U predator-control-*.pkg.tar.zst
|
# sudo pacman -U predator-control-*.pkg.tar.zst
|
||||||
|
|
||||||
pkgname=predator-control
|
pkgname=predator-control
|
||||||
pkgver=1.1.0
|
pkgver=1.2.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="PredatorSense replacement for Acer Predator laptops: performance modes, fans, keyboard RGB, lightbar, battery (Qt app + Plasma widget)"
|
pkgdesc="PredatorSense replacement for Acer Predator laptops: performance modes, fans, keyboard RGB, lightbar, battery (Qt app + Plasma widget)"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
@@ -22,7 +22,7 @@ install=predator-control.install
|
|||||||
source=()
|
source=()
|
||||||
|
|
||||||
_src="$(realpath "$startdir/../..")"
|
_src="$(realpath "$startdir/../..")"
|
||||||
_modver=1.1.0
|
_modver=1.2.0
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$_src"
|
cd "$_src"
|
||||||
|
|||||||
Executable
+72
@@ -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)
|
? 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"
|
+ "\nFans " + (st.sensors.cpu_fan || 0) + " / " + (st.sensors.gpu_fan || 0) + " RPM"
|
||||||
+ "\nBattery " + batteryText()
|
+ "\nBattery " + batteryText()
|
||||||
|
+ (st.update && st.update.available ? "\nUpdate " + st.update.latest + " available" : "")
|
||||||
: "Service predatord not running"
|
: "Service predatord not running"
|
||||||
|
|
||||||
P5Support.DataSource {
|
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 {
|
PC3.Label {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: root.lastError !== ""
|
visible: root.lastError !== ""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"Id": "org.predatorcontrol.panel",
|
"Id": "org.predatorcontrol.panel",
|
||||||
"License": "GPL-2.0-only",
|
"License": "GPL-2.0-only",
|
||||||
"Name": "Predator Control",
|
"Name": "Predator Control",
|
||||||
"Version": "1.1.0",
|
"Version": "1.2.0",
|
||||||
"Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
|
"Website": "https://git.mrblake.cc/MrBlake/Cachy-Predator-Keyboard"
|
||||||
},
|
},
|
||||||
"X-Plasma-API-Minimum-Version": "6.0",
|
"X-Plasma-API-Minimum-Version": "6.0",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
__version__ = "1.1.0"
|
__version__ = "1.2.0"
|
||||||
|
|
||||||
APP_NAME = "Predator Control"
|
APP_NAME = "Predator Control"
|
||||||
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
|
RUN_DIR = os.environ.get("PREDATOR_CONTROL_RUN_DIR", "/run/predator-control")
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ def main(argv=None):
|
|||||||
sp = sub.add_parser("battery-limit", help="80 %% charge limit")
|
sp = sub.add_parser("battery-limit", help="80 %% charge limit")
|
||||||
sp.add_argument("value", choices=["on", "off"])
|
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 = sub.add_parser("open", help="open the main window")
|
||||||
sp.add_argument("page", nargs="?", default="home")
|
sp.add_argument("page", nargs="?", default="home")
|
||||||
|
|
||||||
@@ -139,6 +143,21 @@ def main(argv=None):
|
|||||||
if a.sync:
|
if a.sync:
|
||||||
args["sync_keyboard"] = a.sync == "on"
|
args["sync_keyboard"] = a.sync == "on"
|
||||||
c.call("set_lightbar", **args)
|
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":
|
elif a.cmd == "battery-limit":
|
||||||
c.call("set_battery_limit", enabled=_bool(a.value))
|
c.call("set_battery_limit", enabled=_bool(a.value))
|
||||||
except DaemonError as e:
|
except DaemonError as e:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import traceback
|
|||||||
|
|
||||||
from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__
|
from . import CONFIG_DIR, CONFIG_PATH, SOCKET_PATH, STATE_PATH, __version__
|
||||||
from . import hardware as hw
|
from . import hardware as hw
|
||||||
|
from . import updater
|
||||||
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
|
from .keyboard import EFFECTS, NUM_CELLS, Keyboard
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
@@ -47,6 +48,7 @@ DEFAULT_CONFIG = {
|
|||||||
},
|
},
|
||||||
"battery": {"limit": None}, # None = leave the firmware state alone
|
"battery": {"limit": None}, # None = leave the firmware state alone
|
||||||
"keyboard_presets": {},
|
"keyboard_presets": {},
|
||||||
|
"updates": {"auto_check": True, "interval_hours": 6},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -93,6 +95,9 @@ class Daemon:
|
|||||||
self.state = {}
|
self.state = {}
|
||||||
self.battery_limit = None
|
self.battery_limit = None
|
||||||
self._ticks = 0
|
self._ticks = 0
|
||||||
|
self.update_info = None
|
||||||
|
self.update_error = None
|
||||||
|
self._update_checking = False
|
||||||
|
|
||||||
# ---------------------------------------------------------- Configuration
|
# ---------------------------------------------------------- Configuration
|
||||||
def _load_config(self):
|
def _load_config(self):
|
||||||
@@ -230,6 +235,9 @@ class Daemon:
|
|||||||
"lightbar_effective": self.lightbar_effective(),
|
"lightbar_effective": self.lightbar_effective(),
|
||||||
"fan_curve_applied": self.curve_applied,
|
"fan_curve_applied": self.curve_applied,
|
||||||
"battery_limit": self.battery_limit,
|
"battery_limit": self.battery_limit,
|
||||||
|
"update": self.update_info,
|
||||||
|
"update_error": self.update_error,
|
||||||
|
"update_status": updater.status(),
|
||||||
"available": {
|
"available": {
|
||||||
"keyboard": self.kbd_present,
|
"keyboard": self.kbd_present,
|
||||||
"wmi": hw.wmi_available(hw.GUID_GAMING),
|
"wmi": hw.wmi_available(hw.GUID_GAMING),
|
||||||
@@ -241,6 +249,34 @@ class Daemon:
|
|||||||
}
|
}
|
||||||
return st
|
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):
|
def loop(self):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -261,6 +297,8 @@ class Daemon:
|
|||||||
self.last_ac = ac
|
self.last_ac = ac
|
||||||
self.fan_curve_tick()
|
self.fan_curve_tick()
|
||||||
self._ticks += 1
|
self._ticks += 1
|
||||||
|
if self._ticks % 20 == 1:
|
||||||
|
self.maybe_check_update()
|
||||||
if self._ticks % 40 == 0:
|
if self._ticks % 40 == 0:
|
||||||
self.refresh_battery_limit()
|
self.refresh_battery_limit()
|
||||||
self.state = self.build_state()
|
self.state = self.build_state()
|
||||||
@@ -279,6 +317,17 @@ class Daemon:
|
|||||||
if cmd == "state":
|
if cmd == "state":
|
||||||
self.state = self.build_state()
|
self.state = self.build_state()
|
||||||
return self.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":
|
if cmd == "restore":
|
||||||
self.restore()
|
self.restore()
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
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.QtNetwork import QLocalServer, QLocalSocket
|
||||||
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
|
from PySide6.QtWidgets import (QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame,
|
||||||
QGridLayout, QHBoxLayout, QInputDialog, QLabel, QListWidget,
|
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.fw_card.body.addWidget(_muted("Values are stored in the laptop firmware (EC/BIOS)."))
|
||||||
self.lay.addWidget(self.fw_card)
|
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")
|
svc = Card("Status")
|
||||||
self.status = _muted("")
|
self.status = _muted("")
|
||||||
svc.body.addWidget(self.status)
|
svc.body.addWidget(self.status)
|
||||||
@@ -728,7 +752,37 @@ class SystemPage(Page):
|
|||||||
box.setChecked(bool(info.get("value")))
|
box.setChecked(bool(info.get("value")))
|
||||||
box.setToolTip(info.get("error", "") or "")
|
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):
|
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"]
|
a = st["available"]
|
||||||
yes = lambda v: "✔" if v else "✘"
|
yes = lambda v: "✔" if v else "✘"
|
||||||
lines = ["%s Service predatord %s" % (yes(True), st.get("version")),
|
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:
|
if self.stack.currentWidget() is not bat_page:
|
||||||
bat_page.indicator.set_battery(st["battery"]) # keep the power history going
|
bat_page.indicator.set_battery(st["battery"]) # keep the power history going
|
||||||
s = st["sensors"]
|
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"),
|
PROFILE_INFO.get(st["profile"], (st["profile"],))[0], s.get("cpu_temp"),
|
||||||
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
|
("%s °C" % s["gpu_temp"]) if s.get("gpu_temp") else "off"))
|
||||||
|
|
||||||
def _done(self, cmd, result, err):
|
def _done(self, cmd, result, err):
|
||||||
if err:
|
if err:
|
||||||
self.statusBar().showMessage("Error: %s" % err, 8000)
|
self.statusBar().showMessage("Error: %s" % err, 8000)
|
||||||
|
elif cmd == "install_update":
|
||||||
|
self.statusBar().showMessage("Update is being installed…", 8000)
|
||||||
elif cmd == "get_firmware":
|
elif cmd == "get_firmware":
|
||||||
self.pages["system"][0].firmware(result)
|
self.pages["system"][0].firmware(result)
|
||||||
self.pages["battery"][0].firmware(result)
|
self.pages["battery"][0].firmware(result)
|
||||||
@@ -860,6 +918,9 @@ def main(argv=None):
|
|||||||
app.setDesktopFileName("predator-control")
|
app.setDesktopFileName("predator-control")
|
||||||
app.setStyle("Fusion")
|
app.setStyle("Fusion")
|
||||||
app.setStyleSheet(theme.QSS)
|
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
|
# single instance: forward the requested page to a running window
|
||||||
name = "predator-control-%d" % os.getuid()
|
name = "predator-control-%d" % os.getuid()
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user