diff options
Diffstat (limited to 'desktop/modules/updates/updatesctl')
| -rwxr-xr-x | desktop/modules/updates/updatesctl | 212 |
1 files changed, 211 insertions, 1 deletions
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__": |
