aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/modules/updates/test_updatesctl.py140
-rwxr-xr-xdesktop/modules/updates/updatesctl212
2 files changed, 351 insertions, 1 deletions
diff --git a/desktop/modules/updates/test_updatesctl.py b/desktop/modules/updates/test_updatesctl.py
index 09c3676..b9e3881 100644
--- a/desktop/modules/updates/test_updatesctl.py
+++ b/desktop/modules/updates/test_updatesctl.py
@@ -18,6 +18,11 @@ never the live system. Plain asserts, no framework.
import importlib.machinery
import importlib.util
+import json
+import os
+import shutil
+import subprocess
+import tempfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent / "updatesctl"
@@ -39,4 +44,139 @@ assert u.vercmp("9.018.00_6.18.41", "9.018.00") > 0
assert u.vercmp("3.1-659215d", "3.1") > 0
assert u.vercmp("1.0.1", "1.0rc1") > 0
+# --- fixture tree ---
+tmp = Path(tempfile.mkdtemp(prefix="updatesctl-test-"))
+home = tmp / "home"
+
+
+def w(path, text=""):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+
+
+def info(repo_rel, version):
+ w(home / "Programming/GIT/slackbuilds-superrepo" / repo_rel,
+ f'PRGNAM="x"\nVERSION="{version}"\n')
+
+
+def sbo(rel, version):
+ w(tmp / "var/lib/sbopkg/SBo-git" / rel, f'VERSION="{version}"\n')
+
+
+def pkgtxt(entries):
+ return "".join(f"PACKAGE NAME: {full}.txz\nPACKAGE LOCATION: ./{loc}\n\n"
+ for loc, full in entries)
+
+
+installed = [
+ "kitty-0.49.1-x86_64-1_danix", # built 0.49.2 -> upgrade
+ "gamescope-3.16.30-x86_64-1_danix", # recipe bumped -> build
+ "discord-1.0.159-x86_64-1_danix", # recipe behind -> upstream
+ "claude-code-2.1.278-x86_64-1_SBo", # ahead of SBo -> upstream
+ "nessus-10.12.4-x86_64-1_SBo", # SBo current -> build
+ "neovim-0.12.6-x86_64-1_SBo", # current -> nothing
+ "r8125-9.018.00_6.18.41-x86_64-1_danix", # suffix -> nothing
+ "solvespace-3.1-659215d-x86_64-1_danix", # dash version -> nothing
+ "glibc-2.44-x86_64-3", # mirror has -4 -> slackware upgrade
+ "bash-5.3-x86_64-1", # testing/ newer, blacklisted -> nothing
+ "dhcp-4.4.3-x86_64-1", # removed upstream -> slackware removed
+ "foo-1.0-x86_64-1_SBo", # blacklisted build -> nothing
+]
+for f in installed:
+ w(tmp / "var/log/packages" / f)
+
+w(home / ".config/nvchecker/new_ver.json", json.dumps({"version": 2, "data": {
+ "kitty": {"version": "0.49.2"},
+ "gamescope": {"version": "3.16.31"},
+ "discord": {"version": "1.0.162"},
+ "claude-code": {"version": "2.1.300"},
+ "nessus": {"version": "10.12.5"},
+ "neovim": {"version": "0.12.6"},
+ "r8125": {"version": "9.018.00"},
+ "solvespace": {"version": "3.1"},
+ "exploitdb": {"version": "2026_10_02"}, # not installed, my recipe behind
+ "obsidian": {"version": "1.14.4"}, # not installed, SBo behind: hidden
+ "Typora": {"version": "1.14.10"}, # AppImage behind
+ "GIMP": {"version": "3.0.6"}, # flatpak behind
+}}))
+
+info("my-slackbuilds/kitty-bin/kitty-bin.info", "0.49.2")
+info("my-slackbuilds/gamescope/gamescope.info", "3.16.31")
+info("my-slackbuilds/network/discord/discord.info", "1.0.161")
+info("my-slackbuilds/r8125/r8125.info", "9.018.00")
+info("my-slackbuilds/solvespace/solvespace.info", "3.1")
+info("sbo-slackbuilds/system/exploitdb/exploitdb.info", "2026_09_12")
+sbo("development/claude-code-bin/claude-code-bin.info", "2.1.267")
+sbo("network/nessus/nessus.info", "10.12.5")
+sbo("development/neovim/neovim.info", "0.12.4")
+sbo("office/obsidian/obsidian.info", "1.13.7")
+
+w(home / ".local/state/appimage-updater/installed.json",
+ json.dumps({"Typora": {"version": "1.14.9"}}))
+w(tmp / "bin/flatpak", "#!/bin/sh\nprintf 'org.gimp.GIMP\\t3.0.4\\n'\n")
+(tmp / "bin/flatpak").chmod(0o755)
+
+w(tmp / "etc/slackpkg/slackpkgplus.conf", "MIRRORPLUS['danix']=dir://repo/\n")
+w(tmp / "repo/PACKAGES.TXT", pkgtxt([
+ ("devel/kitty", "kitty-0.49.2-x86_64-1_danix"),
+ ("games/gamescope", "gamescope-3.16.30-x86_64-1_danix"),
+]))
+
+w(tmp / "etc/slackpkg/mirrors", "# a comment\nfile://mnt/mirror/\n")
+w(tmp / "etc/slackpkg/blacklist", '"testing/"\n[0-9]+_SBo\n')
+w(tmp / "mnt/mirror/PACKAGES.TXT", pkgtxt([
+ ("slackware64/l", "glibc-2.44-x86_64-4"),
+ ("slackware64/a", "bash-5.3-x86_64-1"),
+ ("testing/packages", "bash-5.4-x86_64-1"),
+ ("slackware64/a", "foo-1.1-x86_64-1"),
+ ("slackware64/l", "libtatsu-1.0.5-x86_64-1"),
+]))
+w(tmp / "mnt/mirror/ChangeLog.txt", """\
+Wed Oct 14 21:48:34 UTC 2026
+l/glibc-2.44-x86_64-4.txz: Rebuilt.
+l/libtatsu-1.0.5-x86_64-1.txz: Upgraded.
+n/dhcp-4.4.3-x86_64-1.txz: Removed.
++--------------------------+
+Thu Oct 1 10:00:00 UTC 2026
+x/oldthing-1.0-x86_64-1.txz: Removed.
+l/libtatsu-1.0.4-x86_64-1.txz: Added.
++--------------------------+
+Tue Sep 1 10:00:00 UTC 2026
+x/oldthing-1.0-x86_64-1.txz: Added.
+""")
+
+
+def run(*args, timeout="5"):
+ env = {"HOME": str(home), "UPDATESCTL_ROOT": str(tmp),
+ "UPDATESCTL_NFS_TIMEOUT": timeout,
+ "PATH": f"{tmp}/bin:/usr/bin:/bin"}
+ r = subprocess.run(["/usr/bin/python3", str(SCRIPT), *args],
+ capture_output=True, text=True, env=env, timeout=30)
+ assert r.returncode == 0, r.stderr
+ return json.loads(r.stdout)
+
+
+def rows(section):
+ return {x["name"]: (x["repo"], x["from"], x["to"]) for x in section}
+
+
+d = run()
+assert d["errors"] == {}, d["errors"]
+assert d["checked"] > 0
+assert rows(d["upgrade"]) == {"kitty": ("personal", "0.49.1", "0.49.2")}, d["upgrade"]
+assert rows(d["build"]) == {
+ "gamescope": ("personal", "3.16.30", "3.16.31"),
+ "nessus": ("SBo", "10.12.4", "10.12.5"),
+}, d["build"]
+assert rows(d["upstream"]) == {
+ "discord": ("personal", "1.0.161", "1.0.162"),
+ "claude-code": ("SBo", "2.1.278", "2.1.300"),
+ "exploitdb": ("my-sbo", "2026_09_12", "2026_10_02"),
+}, d["upstream"]
+assert rows(d["apps"]) == {
+ "Typora": ("AppImage", "1.14.9", "1.14.10"),
+ "GIMP": ("flatpak", "3.0.4", "3.0.6"),
+}, d["apps"]
+
+shutil.rmtree(tmp)
print("ok")
diff --git a/desktop/modules/updates/updatesctl b/desktop/modules/updates/updatesctl
index c1e69ed..413bbcd 100755
--- a/desktop/modules/updates/updatesctl
+++ b/desktop/modules/updates/updatesctl
@@ -67,8 +67,218 @@ def vercmp(a, b):
return sign if longer[min(len(x), len(y))].isdigit() else -sign
+NVCONF = HOME / ".config/nvchecker/nvchecker.toml"
+NEWVER = HOME / ".config/nvchecker/new_ver.json"
+SUPER = HOME / "Programming/GIT/slackbuilds-superrepo"
+SBO = "/var/lib/sbopkg/SBo-git"
+AI_STATE = (Path(os.environ.get("XDG_STATE_HOME") or HOME / ".local/state")
+ / "appimage-updater/installed.json")
+
+# Repo directory -> the label nvtable shows for it.
+REPOS = [("sbo-slackbuilds", "my-sbo"), ("my-slackbuilds", "personal"),
+ ("Slackware-Pentesting-Suite", "pentesting")]
+
+# From nvtable. Stanza name -> installed package name(s), where they differ.
+ALIAS = {
+ "kvantum": ["kvantum-qt5", "kvantum-qt6"],
+ "metasploit-framework": ["metasploit-framework-bin"],
+ "Typora": ["typora-bin"],
+}
+
+# From nvtable. Stanza name -> SlackBuild directory, where they differ.
+REPO_ALIAS = {
+ "kitty": ["kitty-bin"],
+ "claude-code": ["claude-code-bin"],
+ "kvantum": ["kvantum-qt6"],
+ "metasploit-framework": ["metasploit-framework-bin"],
+ "Typora": ["typora-bin"],
+}
+
+
+def read(path):
+ try:
+ return Path(path).read_text(errors="replace")
+ except OSError:
+ return ""
+
+
+def nfs_read(path):
+ """A file on the NFS share, or None. The mount is hard, so a read during
+ a NAS outage blocks: it runs in a child that the timeout kills."""
+ try:
+ r = subprocess.run(["cat", str(path)], capture_output=True,
+ timeout=NFS_TIMEOUT)
+ except subprocess.TimeoutExpired:
+ return None
+ return r.stdout.decode(errors="replace") if r.returncode == 0 else None
+
+
+def split(full):
+ """name-version-arch-build. Right for Slackware's own packages, whose
+ versions never contain a dash; tracked ones go through version_in()."""
+ return full.rsplit("-", 3)
+
+
+def version_in(fulls, names):
+ """The version of the first of names found among package file names.
+
+ Anchored on "<name>-" followed by a digit, so a version containing a
+ dash (solvespace-3.1-659215d) survives and a longer sibling
+ (kvantum-qt5 when asked for kvantum) is never taken for a match."""
+ for want in names:
+ for full in fulls:
+ stem = full.rsplit("-", 2)[0]
+ if stem.lower().startswith(want.lower() + "-"):
+ rest = stem[len(want) + 1:]
+ if rest[:1].isdigit():
+ return rest
+ return ""
+
+
+def packages_txt(text):
+ """PACKAGES.TXT as (location, full name) pairs, location without ./"""
+ out, name = [], None
+ for line in text.splitlines():
+ if line.startswith("PACKAGE NAME:"):
+ name = re.sub(r"\.t[gx]z$", "", line.split(":", 1)[1].strip())
+ elif line.startswith("PACKAGE LOCATION:") and name:
+ out.append((line.split(":", 1)[1].strip().lstrip("./"), name))
+ name = None
+ return out
+
+
+def local_dir(url):
+ m = re.match(r"(?:file|dir)://(.*)", url.strip())
+ return sysp("/" + m.group(1).lstrip("/")) if m else None
+
+
+def repo_dir():
+ m = re.search(r"^MIRRORPLUS\['danix'\]=(\S+)",
+ read(sysp("/etc/slackpkg/slackpkgplus.conf")), re.M)
+ return local_dir(m.group(1)) if m else None
+
+
+def info_version(path):
+ m = re.search(r'^VERSION="(.*)"$', read(path), re.M)
+ return m.group(1) if m else ""
+
+
+def recipes():
+ """SlackBuild directory -> (version, label) over the user's repos."""
+ out = {}
+ for d, label in REPOS:
+ base = SUPER / d
+ for inf in sorted(list(base.glob("*/*.info")) + list(base.glob("*/*/*.info"))):
+ v = info_version(inf)
+ if v:
+ out.setdefault(inf.stem, (v, label))
+ return out
+
+
+def recipe_of(name, mine):
+ """(version, label) of the recipe: the user's first, then the SBo clone."""
+ cands = [name] + REPO_ALIAS.get(name, []) + ALIAS.get(name, [])
+ for c in cands:
+ if c in mine:
+ return mine[c]
+ for c in cands:
+ for inf in sysp(SBO).glob(f"*/{c}/{c}.info"):
+ v = info_version(inf)
+ if v:
+ return v, "SBo"
+ return "", ""
+
+
+def apps():
+ """Lowercased app key -> (version, source) for AppImages and flatpaks."""
+ out = {}
+ try:
+ for k, v in json.loads(read(AI_STATE) or "{}").items():
+ out[k.lower()] = (v.get("version", ""), "AppImage")
+ except (ValueError, AttributeError):
+ pass
+ if shutil.which("flatpak"):
+ r = subprocess.run(["flatpak", "list", "--app", "--columns=application,version"],
+ capture_output=True, text=True)
+ for line in r.stdout.splitlines():
+ app, _, v = line.partition("\t")
+ if app:
+ out[app.rsplit(".", 1)[-1].lower()] = (v, "flatpak")
+ return out
+
+
+def row(name, repo, frm, to):
+ return {"name": name, "repo": repo, "from": frm, "to": to}
+
+
+def tracked(installed, errors):
+ out = {k: [] for k in ("upgrade", "build", "upstream", "apps")}
+ try:
+ data = json.loads(NEWVER.read_text())
+ except (OSError, ValueError):
+ errors["nvchecker"] = "no results"
+ return out
+ data = data.get("data", data)
+
+ rd = repo_dir()
+ pk = nfs_read(rd / "PACKAGES.TXT") if rd else None
+ if pk is None:
+ errors["repo"] = "unreachable"
+ built = [full for _, full in packages_txt(pk or "")]
+ mine, app = recipes(), apps()
+
+ for name in sorted(data, key=str.lower):
+ v = data[name]
+ up = v.get("version", "") if isinstance(v, dict) else str(v)
+ names = ALIAS.get(name, [name])
+
+ i = version_in(installed, names)
+ if not i:
+ hit = next((app[n.lower()] for n in [name] + names if n.lower() in app), None)
+ if hit:
+ if vercmp(hit[0], up) < 0:
+ out["apps"].append(row(name, hit[1], hit[0], up))
+ continue
+
+ r, label = recipe_of(name, mine)
+ b = version_in(built, [name] + names + REPO_ALIAS.get(name, []))
+ label = label or "--"
+
+ if i and b and vercmp(b, i) > 0:
+ out["upgrade"].append(row(name, label, i, b))
+ elif i and r and vercmp(r, up) >= 0 and (not b or vercmp(b, up) < 0):
+ if vercmp(i, up) < 0:
+ out["build"].append(row(name, label, b or i, up))
+ elif i or (r and label != "SBo"):
+ # ponytail: base is the newest of recipe, build and install; an
+ # installed version ahead of every recipe still counts as behind
+ # upstream when upstream moved past it.
+ base = max((x for x in (r, b, i) if x),
+ key=cmp_to_key(vercmp), default="")
+ if base and vercmp(up, base) > 0:
+ out["upstream"].append(row(name, label, base, up))
+ return out
+
+
def main():
- pass
+ errors = {}
+ if "--refresh" in sys.argv[1:]:
+ # Exit status is not checked: nvchecker exits 0 with failed stanzas
+ # logged, and a partial run still rewrites new_ver.json.
+ try:
+ subprocess.run(["nvchecker", "-c", str(NVCONF)],
+ capture_output=True, timeout=600)
+ except (OSError, subprocess.SubprocessError):
+ errors["nvchecker"] = "refresh failed"
+ try:
+ installed = os.listdir(sysp("/var/log/packages"))
+ except OSError:
+ installed = []
+ doc = {"checked": int(NEWVER.stat().st_mtime) if NEWVER.exists() else 0}
+ doc.update(tracked(installed, errors))
+ doc["errors"] = errors
+ json.dump(doc, sys.stdout)
+ print()
if __name__ == "__main__":