git/orchard/main
updater/flatpak.py
189 lines6 KB last change raw · history
"""Flatpak updates, read through `--columns`.

`--columns` is flatpak's documented machine-readable interface, so this is a real
integration rather than output scraping. The one trap is that flatpak will print
`error: ...` on stderr and still exit 0 in some failure modes, so the return code
alone is not enough to decide whether the answer is trustworthy.
"""

from __future__ import annotations

import shutil
import subprocess
from dataclasses import dataclass, field

COLUMNS = "application,branch,origin,version"
TIMEOUT = 60


@dataclass(frozen=True)
class FlatpakRef:
    application: str
    version: str = ""
    branch: str = ""
    origin: str = ""
    kind: str = "app"

    @property
    def label(self) -> str:
        bits = [self.application]
        if self.version:
            bits.append(self.version)
        elif self.branch:
            bits.append(self.branch)
        return " ".join(bits)


@dataclass
class FlatpakState:
    available: bool = False
    reason: str = ""
    remotes: list[str] = field(default_factory=list)
    installed: list[FlatpakRef] = field(default_factory=list)
    updates: list[FlatpakRef] = field(default_factory=list)

    @property
    def has_updates(self) -> bool:
        return bool(self.updates)

    @property
    def short_reason(self) -> str:
        """A reason short enough for a one-line check row.

        flatpak's own messages embed absolute paths and quoted filenames, which
        are noise in a summary; the actionable part is which remote failed.
        """
        text = self.reason.strip()
        if not text:
            return ""
        for prefix in ("error: ", "Error: "):
            text = text.removeprefix(prefix)
        lowered = text.lower()
        if "unable to load summary from remote" in lowered:
            remote = text.split("remote", 1)[1].strip().split()[0].rstrip(":")
            return f"remote {remote} unreachable"
        if "read-only file system" in lowered:
            return "cache is read-only"
        return text.split(":")[0][:48] if ":" in text else text[:48]

    @property
    def app_updates(self) -> list[FlatpakRef]:
        return [u for u in self.updates if u.kind == "app"]


def flatpak_binary() -> str | None:
    return shutil.which("flatpak")


def _run(args: list[str], *, timeout: int = TIMEOUT) -> tuple[int, str, str]:
    binary = flatpak_binary()
    if binary is None:
        return 127, "", "flatpak is not installed"
    try:
        proc = subprocess.run(  # noqa: S603 - fixed argv
            [binary, *args],
            capture_output=True,
            text=True,
            errors="replace",
            timeout=timeout,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return 124, "", f"flatpak {' '.join(args)} timed out after {timeout}s"
    except OSError as exc:
        return 126, "", f"could not run flatpak: {exc}"
    return proc.returncode, proc.stdout, proc.stderr


def _looks_failed(returncode: int, stderr: str) -> str:
    """An empty reason means the result can be trusted."""
    if returncode != 0:
        return (stderr.strip().splitlines() or [f"exit status {returncode}"])[0]
    for line in stderr.splitlines():
        stripped = line.strip()
        if stripped.lower().startswith("error:"):
            return stripped
    return ""


def _parse(text: str, *, kind: str) -> list[FlatpakRef]:
    out: list[FlatpakRef] = []
    for line in text.splitlines():
        if not line.strip():
            continue
        parts = line.split("\t")
        while len(parts) < 4:
            parts.append("")
        application, branch, origin, version = (p.strip() for p in parts[:4])
        if not application:
            continue
        out.append(
            FlatpakRef(
                application=application, version=version, branch=branch, origin=origin, kind=kind
            )
        )
    return out


def _dedupe(refs: list[FlatpakRef]) -> list[FlatpakRef]:
    seen: dict[tuple[str, str, str], FlatpakRef] = {}
    for ref in refs:
        seen.setdefault((ref.kind, ref.application, ref.branch), ref)
    return sorted(seen.values(), key=lambda r: (r.kind, r.application))


def remotes() -> tuple[list[str], str]:
    code, out, err = _run(["remotes", "--columns=name"])
    reason = _looks_failed(code, err)
    if reason:
        return [], reason
    return [line.strip() for line in out.splitlines() if line.strip()], ""


def collect(*, include_runtimes: bool = False, with_installed: bool = True) -> FlatpakState:
    """One call answers everything the plan needs about flatpak.

    Never raises: flatpak being absent, broken or offline is a normal state, and
    the caller decides what to say about it.
    """
    state = FlatpakState()
    if flatpak_binary() is None:
        state.reason = "flatpak is not installed"
        return state

    state.remotes, reason = remotes()
    if reason and not state.remotes:
        state.reason = reason
        return state

    if with_installed:
        code, out, err = _run(["list", "--columns=" + COLUMNS])
        bad = _looks_failed(code, err)
        if bad:
            state.reason = bad
            return state
        state.installed = _parse(out, kind="app")

    kinds = ["--app"] + (["--runtime"] if include_runtimes else [])
    updates: list[FlatpakRef] = []
    for flag in kinds:
        code, out, err = _run(["remote-ls", "--updates", flag, "--columns=" + COLUMNS])
        bad = _looks_failed(code, err)
        if bad:
            # an unreachable remote is a reportable condition, not a crash
            state.reason = bad
            return state
        updates.extend(_parse(out, kind="app" if flag == "--app" else "runtime"))

    state.available = True
    state.updates = _dedupe(updates)
    return state


def update_command(*, include_runtimes: bool = True) -> list[str]:
    """The argv to apply updates non-interactively."""
    args = ["flatpak", "update", "--noninteractive", "-y"]
    if not include_runtimes:
        args.append("--no-related")
    return args