Make Applications a real app manager, and clean up storage without the racket
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
+583
@@ -0,0 +1,583 @@
|
||||
#!/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 <id>` for a flatpak and `rpm -q <name>`
|
||||
# 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:]))
|
||||
Reference in New Issue
Block a user