#!/usr/bin/env python3 """What is installed, what Panama offers to install, and what a flatpak may do. Three boundaries, deliberately separate: flatpaks / permissions read-only questions about what is already here. catalog the same optional-application catalog `panama apps` and ./install read, parsed by the same rules. install / uninstall mutations, each one validated against the catalog or against what flatpak reports as installed. Deliberately absent: removing dnf packages. A settings page that uninstalls system packages is one mis-click away from removing the compositor it is drawn by, and dnf's dependency resolution will happily take half the desktop with it. The Applications page says "installed by the system package manager" and names the command instead. There is no dnf removal path anywhere in this file, and the contract checks for one. The catalog is a closed surface: `install` refuses any entry that is not in setup/packages/extras, so this helper can never become a way to install an arbitrary package by passing a different string. panama-applications flatpaks panama-applications permissions APP_ID panama-applications uninstall APP_ID panama-applications unused-runtimes panama-applications clean-unused panama-applications catalog panama-applications install CATEGORY ENTRY_ID """ from __future__ import annotations import configparser import json import os import re import shutil import subprocess import sys from pathlib import Path # Flathub ids, dnf package names, and category file names. Everything that # reaches a command line is matched against one of these first. APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$") PACKAGE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$") CATEGORY_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") # flatpak prints sizes for people, not for programs: there is no machine-readable # column for the installed size in any released flatpak. The display string is # kept as-is for the page, and the parsed number is only ever used for the # storage breakdown, which says out loud that it is flatpak's own rounded figure. SIZE_UNITS = { "b": 1, "byte": 1, "bytes": 1, "kb": 10**3, "mb": 10**6, "gb": 10**9, "tb": 10**12, "kib": 1024, "mib": 1024**2, "gib": 1024**3, "tib": 1024**4, } SIZE = re.compile(r"^\s*([0-9]+(?:[.,][0-9]+)?)\s*([A-Za-z]+)\s*$") # The remote every catalog flatpak comes from. install-packages adds it during # setup; naming it here keeps a machine with several remotes from resolving an # id against whichever one happens to be first. FLATHUB = "flathub" class BoundaryError(RuntimeError): """A user-visible validation or command failure.""" def run(command: list[str], timeout: float = 30.0) -> subprocess.CompletedProcess: try: return subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False) except subprocess.TimeoutExpired as error: raise BoundaryError(f"{command[0]} did not answer in time.") from error except OSError as error: raise BoundaryError(f"{command[0]} is not available.") from error def require_flatpak() -> None: if not shutil.which("flatpak"): raise BoundaryError("Flatpak is not installed on this machine.") def parse_size(text: str) -> int | None: """flatpak's own size string as bytes, or nothing when it cannot be read. Nothing is not zero. A size this cannot parse is reported as unmeasured so the storage breakdown can say so, rather than quietly shrinking the applications segment and inflating the remainder. """ match = SIZE.match(text or "") if not match: return None number, unit = match.groups() factor = SIZE_UNITS.get(unit.lower()) if factor is None: return None try: return int(float(number.replace(",", ".")) * factor) except ValueError: return None def flatpak_rows(scope: list[str], columns: list[str]) -> list[list[str]]: """Tab-separated `flatpak list` output as rows, or nothing when it fails.""" result = run(["flatpak", "list", *scope, "--columns=" + ",".join(columns)]) if result.returncode != 0: return [] rows = [] for line in result.stdout.splitlines(): if not line.strip(): continue fields = line.split("\t") # A column flatpak could not fill comes back empty rather than missing, # but a future flatpak that drops one should not throw an IndexError # across the whole page. fields += [""] * (len(columns) - len(fields)) rows.append(fields[:len(columns)]) return rows def flatpaks() -> list[dict]: require_flatpak() entries = [] for app_id, name, size, origin in flatpak_rows( ["--app"], ["application", "name", "size", "origin"]): entries.append({ "id": app_id, "name": name or app_id, "size": size, "sizeBytes": parse_size(size), "origin": origin, }) entries.sort(key=lambda entry: (entry["name"].casefold(), entry["id"])) return entries # ── Permissions ────────────────────────────────────────────────────────────── # # A flatpak's sandbox is described by a handful of keys whose values are # semicolon-separated tokens. The buckets below are the questions people # actually ask -- can it see my camera, can it read my files -- and everything # that does not land in a bucket still gets a line. A permission summary that # silently omits what it did not recognise is worse than no summary: it reads as # "this app asks for nothing else". CONTEXT_KEYS = ("shared", "sockets", "devices", "filesystems", "features", "persistent") HOST_FILESYSTEMS = {"host", "host-os", "host-etc", "/"} HOME_FILESYSTEMS = {"home", "~", "~/"} DEVICE_LABELS = { "all": "every device", "dri": "graphics acceleration", "kvm": "virtual machines", "shm": "shared memory", "input": "input devices", "usb": "USB devices", } SOCKET_LABELS = { "wayland": "Wayland", "x11": "X11", "fallback-x11": "X11 when Wayland is unavailable", "session-bus": "the whole session bus", "system-bus": "the whole system bus", "ssh-auth": "your SSH agent", "gpg-agent": "your GPG agent", "cups": "printing", "pcsc": "smart cards", "inherit-wayland-socket": "an inherited Wayland socket", } def permission_sections(text: str) -> dict[str, dict[str, str]]: parser = configparser.RawConfigParser(strict=False) # Keys are lower-case already in the [Context] section, but bus names are # not, and lower-casing org.gnome.Software would make the raw payload lie. parser.optionxform = str try: parser.read_string(text) except configparser.Error as error: raise BoundaryError("That application's permissions could not be read.") from error return {section: dict(parser.items(section)) for section in parser.sections()} def tokens(value: str) -> list[str]: return [item.strip() for item in (value or "").split(";") if item.strip()] def base_path(entry: str) -> str: """`xdg-download:create` names the path `xdg-download`.""" return entry.split(":", 1)[0] def join_some(items: list[str], limit: int = 4) -> str: if len(items) <= limit: return ", ".join(items) return ", ".join(items[:limit]) + f" and {len(items) - limit} more" def permission_summary(sections: dict[str, dict[str, str]]) -> list[str]: context = sections.get("Context", {}) shared = tokens(context.get("shared", "")) sockets = tokens(context.get("sockets", "")) devices = tokens(context.get("devices", "")) filesystems = tokens(context.get("filesystems", "")) summary: list[str] = [] host = [item for item in filesystems if base_path(item) in HOST_FILESYSTEMS] home = [item for item in filesystems if base_path(item) in HOME_FILESYSTEMS] # Camera. Flatpak has no camera token: a webcam is reached through # `devices=all`, so that is what this reports, and it says why. if "all" in devices: summary.append("Camera — full device access reaches webcams") # Microphone. Likewise, audio is one permission in both directions. if "pulseaudio" in sockets: summary.append("Microphone — audio access records as well as plays") if host: summary.append("Full file system access") if home: summary.append("Home folder") if "network" in shared: summary.append("Network") if devices: summary.append("Devices: " + join_some( [DEVICE_LABELS.get(item, item) for item in devices])) # Everything the buckets did not claim, said plainly rather than dropped. rest_shared = [item for item in shared if item != "network"] if rest_shared: summary.append("Also shares: " + join_some(rest_shared)) rest_sockets = [item for item in sockets if item != "pulseaudio"] if rest_sockets: # Sorted by the words shown rather than by flatpak's order, so # "X11 when Wayland is unavailable" follows "X11" instead of leading it. summary.append("Talks to " + join_some( sorted(SOCKET_LABELS.get(item, item) for item in rest_sockets))) rest_files = [item for item in filesystems if item not in host and item not in home] if rest_files: summary.append("Other locations: " + join_some(rest_files)) features = tokens(context.get("features", "")) if features: summary.append("Sandbox features: " + join_some(features)) persistent = tokens(context.get("persistent", "")) if persistent: summary.append("Keeps files in " + join_some(persistent)) for key, value in context.items(): if key in CONTEXT_KEYS: continue items = tokens(value) summary.append(f"Also requests {key}: " + (join_some(items) if items else str(value))) for name, entries in sections.items(): if name == "Context" or not entries: continue count = len(entries) thing = "setting" if count == 1 else "settings" summary.append(f"{name}: {count} {thing}") if not summary: summary.append("Nothing beyond the sandbox defaults") return summary def permissions(app_id: str) -> dict: require_flatpak() if not APP_ID.fullmatch(app_id or ""): raise BoundaryError("That is not an application id.") result = run(["flatpak", "info", "--show-permissions", app_id]) if result.returncode != 0: raise BoundaryError("That application is not installed.") sections = permission_sections(result.stdout) return {"id": app_id, "summary": permission_summary(sections), "raw": sections} # ── Mutations ──────────────────────────────────────────────────────────────── def installed_app_ids() -> set[str]: return {row[0] for row in flatpak_rows(["--app"], ["application"]) if row[0]} def uninstall(app_id: str) -> list[dict]: require_flatpak() if not APP_ID.fullmatch(app_id or ""): raise BoundaryError("That is not an application id.") # --app so a mistyped id can never resolve to a runtime, and the id is # checked against what is actually installed rather than trusting the page. if app_id not in installed_app_ids(): raise BoundaryError("That application is not installed.") result = run(["flatpak", "uninstall", "--app", "--noninteractive", app_id], timeout=300) if result.returncode != 0: raise BoundaryError(_refusal(result, "That application could not be removed.")) return flatpaks() def unused_runtimes() -> list[dict]: """Runtimes and extensions no installed application asks for. This is an estimate, and the page says so: the removal itself is `flatpak uninstall --unused`, which recomputes the list with flatpak's own dependency graph. Showing a size before asking needs a number now, and flatpak offers no way to ask what --unused would do without doing it. """ require_flatpak() apps = flatpak_rows(["--app"], ["application", "runtime"]) runtimes = flatpak_rows(["--runtime"], ["application", "ref", "branch", "size", "runtime"]) # Seeded with the apps, so an application's own extensions -- OBS's plugins # are installed as runtimes named com.obsproject.Studio.Plugin.* -- count as # used rather than as sixteen orphans. used: set[str] = {row[0] for row in apps if row[0]} used |= {row[1].split("/")[0] for row in apps if row[1]} used_refs: set[str] = {row[1] for row in apps if row[1]} # An extension of something used is used, and a runtime's own runtime is # used. Repeated until nothing new appears, because the chain can be two or # three long (app -> Platform -> Platform.Locale). changed = True while changed: changed = False for app_id, ref, _branch, _size, runtime in runtimes: if not app_id or app_id in used: continue parent = next((name for name in used if app_id.startswith(name + ".")), None) if parent is not None or ref in used_refs: used.add(app_id) if runtime: used.add(runtime.split("/")[0]) used_refs.add(runtime) changed = True entries = [] for app_id, ref, branch, size, _runtime in runtimes: if not app_id or app_id in used: continue entries.append({ "id": app_id, "ref": ref, "branch": branch, "size": size, "sizeBytes": parse_size(size), }) entries.sort(key=lambda entry: entry["sizeBytes"] or 0, reverse=True) return entries def clean_unused() -> list[dict]: require_flatpak() result = run(["flatpak", "uninstall", "--unused", "--noninteractive"], timeout=600) if result.returncode != 0: raise BoundaryError(_refusal(result, "The unused runtimes could not be removed.")) return flatpaks() # ── The catalog ────────────────────────────────────────────────────────────── # # The rules below are setup/lib/extras-catalog's, restated in Python because a # QML page cannot source a bash library. They are pinned by a contract that # reads both, so a change to one that is not made to the other fails loudly # rather than producing a menu that installs something else. # # * everything from the first `#` onward is stripped, inline comments included # * blank and whitespace-only lines are skipped, after stripping # * a line beginning with whitespace continues the entry above it and is # installed with it, never listed on its own # * `target | label`, split on the first `|`, both sides trimmed # * with no label, a `flatpak:` id becomes its last dotted component and a dnf # package keeps its own name # * installed means `flatpak info ` for a flatpak and `rpm -q ` # otherwise -- here, membership in one listing of each rather than a process # per entry, which is the same question asked cheaply def extras_directory() -> Path: override = os.environ.get("PANAMA_EXTRAS_DIR") if override: return Path(override) return Path(__file__).resolve().parents[4] / "setup" / "packages" / "extras" def category_files() -> list[Path]: directory = extras_directory() if not directory.is_dir(): raise BoundaryError("The application catalog is not available.") return sorted(path for path in directory.iterdir() if path.is_file() and CATEGORY_NAME.fullmatch(path.name)) def category_file(name: str) -> Path: if not CATEGORY_NAME.fullmatch(name or ""): raise BoundaryError("That is not an application category.") for path in category_files(): if path.name == name: return path raise BoundaryError("That is not an application category.") def catalog_lines(path: Path) -> list[str]: try: text = path.read_text(encoding="utf-8", errors="replace") except OSError as error: raise BoundaryError("The application catalog could not be read.") from error return [line.split("#", 1)[0] for line in text.splitlines()] def split_entry(line: str) -> tuple[str, str]: target, _, label = line.partition("|") target = target.strip() label = label.strip() if not label: label = target[len("flatpak:"):] if target.startswith("flatpak:") else target if target.startswith("flatpak:"): label = label.rsplit(".", 1)[-1] return target, label def catalog_entries(path: Path) -> list[tuple[str, str]]: entries = [] for line in catalog_lines(path): if not line.strip() or line[:1].isspace(): continue target, label = split_entry(line) if target: entries.append((target, label)) return entries def catalog_targets(path: Path, wanted: str) -> list[str]: """The entry itself, then the indented lines beneath it.""" targets: list[str] = [] seen = False for line in catalog_lines(path): if not line.strip(): continue if line[:1].isspace(): if seen: targets.append(line.split("|", 1)[0].strip()) continue target, _ = split_entry(line) if target == wanted: seen = True targets.append(target) elif seen: break return [target for target in targets if target] def installed_sets() -> tuple[set[str], set[str]]: """Every installed flatpak ref name, and every installed rpm name. One listing each rather than `flatpak info` and `rpm -q` per entry: the catalog runs to a hundred entries and the page opens with it. """ flatpak_ids: set[str] = set() if shutil.which("flatpak"): flatpak_ids = {row[0] for row in flatpak_rows([], ["application"]) if row[0]} packages: set[str] = set() if shutil.which("rpm"): result = run(["rpm", "-qa", "--qf", "%{NAME}\\n"], timeout=60) if result.returncode == 0: packages = {line.strip() for line in result.stdout.splitlines() if line.strip()} return flatpak_ids, packages def entry_payload(target: str, label: str, flatpak_ids: set[str], packages: set[str]) -> dict: is_flatpak = target.startswith("flatpak:") ref = target[len("flatpak:"):] if is_flatpak else target return { # The id is the catalog line's target, verbatim, because that is what # `install` matches and what extras-catalog's own lookup takes. "id": target, "ref": ref, "label": label, "kind": "flatpak" if is_flatpak else "dnf", "installed": ref in (flatpak_ids if is_flatpak else packages), } def catalog() -> dict: flatpak_ids, packages = installed_sets() categories = [] for path in category_files(): entries = [entry_payload(target, label, flatpak_ids, packages) for target, label in catalog_entries(path)] categories.append({ "name": path.name, # "gpu-compute" is a file name; "GPU compute" is a heading. The # page needs the second and the helper needs the first. "label": path.name.replace("-", " ").capitalize(), "entries": entries, }) return {"categories": categories} def install(category: str, entry_id: str) -> dict: """Install one catalog entry, and nothing that is not a catalog entry.""" path = category_file(category) known = {target for target, _ in catalog_entries(path)} if entry_id not in known: raise BoundaryError("That application is not in the catalog.") flatpak_targets: list[str] = [] dnf_targets: list[str] = [] for target in catalog_targets(path, entry_id): if target.startswith("flatpak:"): identifier = target[len("flatpak:"):] if not APP_ID.fullmatch(identifier): raise BoundaryError("That catalog entry names something unusable.") flatpak_targets.append(identifier) else: if not PACKAGE_NAME.fullmatch(target): raise BoundaryError("That catalog entry names something unusable.") dnf_targets.append(target) if not flatpak_targets and not dnf_targets: raise BoundaryError("That catalog entry installs nothing.") if flatpak_targets: require_flatpak() result = run(["flatpak", "install", "--noninteractive", FLATHUB, *flatpak_targets], timeout=1800) if result.returncode != 0: raise BoundaryError(_refusal(result, "That application could not be installed.")) if dnf_targets: if not shutil.which("dnf"): raise BoundaryError("dnf is not available on this machine.") # pkexec rather than sudo: the desktop already runs a polkit agent, and # a settings page has no terminal to type a password into. result = run(["pkexec", "dnf", "install", "-y", *dnf_targets], timeout=1800) if result.returncode != 0: raise BoundaryError(_refusal(result, "That package could not be installed.")) return catalog() def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str: text = (result.stderr or result.stdout or "").strip().splitlines() if not text: return fallback last = text[-1].strip() lowered = last.lower() if "not authorized" in lowered or "dismissed" in lowered: return "That change was not authorized." return last[:200] or fallback def emit(payload) -> None: print(json.dumps(payload, separators=(",", ":"))) def main(arguments: list[str]) -> int: try: if arguments == ["flatpaks"]: emit(flatpaks()) elif arguments == ["catalog"]: emit(catalog()) elif arguments == ["unused-runtimes"]: emit(unused_runtimes()) elif arguments == ["clean-unused"]: emit(clean_unused()) elif len(arguments) == 2 and arguments[0] == "permissions": emit(permissions(arguments[1])) elif len(arguments) == 2 and arguments[0] == "uninstall": emit(uninstall(arguments[1])) elif len(arguments) == 3 and arguments[0] == "install": emit(install(arguments[1], arguments[2])) else: raise BoundaryError( "Usage: panama-applications flatpaks | permissions APP_ID | " "uninstall APP_ID | unused-runtimes | clean-unused | catalog | " "install CATEGORY ENTRY_ID") except BoundaryError as error: print(str(error), file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))