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:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
+583
View File
@@ -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:]))
@@ -57,6 +57,15 @@ ROLE_TARGETS = {
}
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
MIME_TYPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}"
r"/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}$")
# A single-type override is a scalpel, not a browser: someone looking for "the
# thing that opens .heic" wants a short answer, and a list of six hundred types
# is not one. Anything past this is reported as truncated so the page can say
# "narrow the search" rather than pretending these are all of them.
TYPE_SEARCH_LIMIT = 20
TYPE_CANDIDATE_LIMIT = 12
class BoundaryError(RuntimeError):
@@ -224,6 +233,182 @@ def set_default(role: str, desktop_id: str) -> None:
run(["xdg-mime", "default", desktop_id, setting])
# ── One file type at a time ──────────────────────────────────────────────────
#
# The roles above govern families, which is right nearly always and wrong
# exactly when a family is too broad: SVG belongs in an editor while the rest of
# the images belong in a viewer, and setting the whole "Images" role to the
# editor is not what anyone wanted. These two verbs are the escape hatch.
#
# Searching is over the type name and its file extensions, and says so on the
# page. Matching human descriptions would mean reading the whole shared-mime-info
# database -- some thousands of small XML files -- to answer a keystroke.
def mime_globs() -> dict[str, list[str]]:
"""Extension patterns per type, from shared-mime-info's globs2.
Absent on a machine without shared-mime-info, which is not fatal: the search
falls back to matching the type name, and every type still resolves.
"""
globs: dict[str, list[str]] = {}
for root in xdg_data_roots():
path = root / "mime" / "globs2"
if not path.is_file():
continue
try:
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
for line in lines:
if line.startswith("#"):
continue
parts = line.split(":")
# weight:type:glob, with optional trailing flags.
if len(parts) < 3:
continue
mime, pattern = parts[1], parts[2]
if not MIME_TYPE.fullmatch(mime):
continue
patterns = globs.setdefault(mime, [])
if pattern not in patterns:
patterns.append(pattern)
return globs
def mime_registrations() -> dict[str, list[str]]:
"""Which installed applications declare which types.
Read from the desktop files themselves rather than mimeinfo.cache, because
the cache is regenerated by update-desktop-database and is stale on exactly
the machine where an application was just installed.
"""
registrations: dict[str, list[str]] = {}
for desktop_id, path in discovered_desktop_files().items():
try:
values = parse_desktop_entry(path)
except BoundaryError:
continue
if values.get("NoDisplay", "false").lower() == "true":
continue
for mime in values.get("MimeType", "").split(";"):
mime = mime.strip()
if not MIME_TYPE.fullmatch(mime):
continue
registered = registrations.setdefault(mime, [])
if desktop_id not in registered:
registered.append(desktop_id)
return registrations
def mime_label(mime: str) -> str:
"""shared-mime-info's own description, or "" when it has none.
Read only for the handful of types a search actually returns.
"""
media, _, subtype = mime.partition("/")
if not media or not subtype:
return ""
for root in xdg_data_roots():
path = root / "mime" / media / f"{subtype}.xml"
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
# The untranslated <comment> comes first; the xml:lang ones follow.
found = re.search(r"<comment>([^<]*)</comment>", text)
if found:
return found.group(1).strip()
return ""
def application_names() -> dict[str, str]:
names: dict[str, str] = {}
for desktop_id, path in discovered_desktop_files().items():
try:
values = parse_desktop_entry(path)
except BoundaryError:
continue
names[desktop_id] = values.get("Name", desktop_id[:-len(".desktop")])
return names
def current_handler(mime: str) -> str:
completed = subprocess.run(["xdg-mime", "query", "default", mime],
check=False, capture_output=True, text=True)
if completed.returncode != 0:
return ""
output = completed.stdout.strip().splitlines()
return output[0] if output else ""
def search_types(query: str) -> dict[str, object]:
needle = (query or "").strip().lower().lstrip(".")
if len(needle) < 2:
return {"query": query, "types": [], "truncated": False}
globs = mime_globs()
registrations = mime_registrations()
names = application_names()
known = sorted(set(globs) | set(registrations))
def score(mime: str) -> tuple[int, str]:
patterns = globs.get(mime, [])
extensions = [pattern[2:].lower() for pattern in patterns
if pattern.startswith("*.")]
if needle in extensions:
return (0, mime)
if mime.lower() == needle or mime.lower().split("/")[-1] == needle:
return (1, mime)
return (2, mime)
matched = []
for mime in known:
patterns = globs.get(mime, [])
haystack = " ".join([mime.lower(),
*(pattern.lower() for pattern in patterns)])
if needle in haystack:
matched.append(mime)
matched.sort(key=score)
truncated = len(matched) > TYPE_SEARCH_LIMIT
types = []
for mime in matched[:TYPE_SEARCH_LIMIT]:
handler = current_handler(mime)
candidates = list(registrations.get(mime, []))
# The current handler belongs in the list even when it never declared
# the type -- an override put it there, and a picker that cannot show
# the answer it is displaying is a picker nobody trusts.
if handler and handler not in candidates:
candidates.insert(0, handler)
candidates = candidates[:TYPE_CANDIDATE_LIMIT]
types.append({
"mime": mime,
"label": mime_label(mime),
"extensions": [pattern[1:] for pattern in globs.get(mime, [])
if pattern.startswith("*.")][:6],
"handler": handler,
"handlerName": names.get(handler, ""),
"candidates": [{"id": desktop_id, "name": names.get(desktop_id, desktop_id)}
for desktop_id in candidates],
})
return {"query": query, "types": types, "truncated": truncated}
def set_type(mime: str, desktop_id: str) -> None:
"""Point one type at one application, leaving its family alone."""
if not MIME_TYPE.fullmatch(mime or ""):
raise BoundaryError("That is not a file type.")
globs = mime_globs()
registrations = mime_registrations()
if mime not in globs and mime not in registrations:
raise BoundaryError("This system does not know that file type.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
run(["xdg-mime", "default", desktop_id, mime])
# What this desktop opens a file with, when nobody has said otherwise.
#
# Applications register themselves for every type they can technically read, so
@@ -442,10 +627,15 @@ def main(arguments: list[str]) -> int:
remove_autostart(arguments[1])
elif len(arguments) == 2 and arguments[0] == "add-autostart":
add_autostart(arguments[1])
elif len(arguments) == 2 and arguments[0] == "search-types":
print(json.dumps(search_types(arguments[1]), separators=(",", ":")))
elif len(arguments) == 3 and arguments[0] == "set-type":
set_type(arguments[1], arguments[2])
else:
raise BoundaryError(
"Usage: panama-default-apps snapshot | seed | set-default ROLE DESKTOP_ID | "
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID"
"set-autostart DESKTOP_ID true|false | add-autostart DESKTOP_ID | "
"remove-autostart DESKTOP_ID | search-types QUERY | set-type MIME DESKTOP_ID"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
+324 -1
View File
@@ -10,11 +10,22 @@ Two boundaries, deliberately separate:
means walking it, and this machine has a 1.2 TiB Steam library.
The page asks for this on demand and remembers the answer.
breakdown what the used space is made of, as four segments and a remainder.
Same walk as `scan`, so it costs the same and is asked for on
demand.
cleanables what could be freed, itemized and sized. Reading only.
clean ID frees exactly one of them, named explicitly.
unmount PATH / eject PATH removable media only, by explicit request.
Deliberately absent: partitioning and formatting. A settings pane is the wrong
place to hand someone a way to erase a disk in two clicks; GNOME Disks is one
button away on the page for that.
Also deliberately absent: anything that runs on its own. Nothing here is
pre-selected, nothing is measured in order to nag about it, and `clean` refuses
every id it was not handed. A storage page that cleans things you did not ask it
to clean is a cleaner, and cleaners are how people lose files.
"""
from __future__ import annotations
@@ -47,6 +58,22 @@ SCAN_TARGETS = [
("Trash", "~/.local/share/Trash"),
]
# The cache folder is its own segment in the breakdown and its own cleanable, so
# it is named once here rather than spelled out in three places.
CACHE_TARGET = "~/.cache"
TRASH_TARGET = "~/.local/share/Trash"
# Where flatpak keeps what it installed. Measured rather than summed from
# `flatpak list --columns=size`: those are per-ref installed sizes, and ostree
# hard-links every object shared between refs, so adding them up on this machine
# reports about twice what the drive actually holds.
FLATPAK_ROOTS = ["/var/lib/flatpak", "~/.local/share/flatpak"]
# dnf keeps downloaded rpms under <repo>/packages and its metadata beside them.
# Only the packages are offered: dropping the metadata costs a re-download on
# the next install and frees comparatively little.
DNF_CACHE_ROOTS = ["/var/cache/libdnf5", "/var/cache/dnf"]
class BoundaryError(RuntimeError):
"""A user-visible validation or command failure."""
@@ -329,6 +356,295 @@ def scan() -> dict:
}
# ── What the used space is made of ───────────────────────────────────────────
#
# Four measured segments and one remainder. The remainder is what is left of the
# filesystem's used bytes after the four are subtracted, and it is labelled
# "System & everything else" on the page for exactly that reason: it is not a
# measurement of the system, it is everything this did not measure.
#
# The arithmetic rule, which a contract pins: the measured segments never sum to
# more than the filesystem reports as used, and nothing is scaled to make a bar
# look tidy. When a measurement does overshoot -- possible when /home lives on a
# different filesystem from the flatpak installation -- the remainder is zero and
# `exceedsUsed` says so rather than inventing a number.
def measure_all(paths: list[str], budget: float) -> tuple[int, float, bool]:
"""Bytes across several paths, and whether every one of them was measured."""
total = 0
complete = True
for target in paths:
if budget <= 1.0:
return total, budget, False
size, budget = measure(Path(os.path.expanduser(target)), budget)
if size is None:
# A path that does not exist contributes nothing and is not a gap;
# one that timed out is, and the budget is gone either way.
if Path(os.path.expanduser(target)).is_dir():
complete = False
continue
total += size
return total, budget, complete
def backing_device(path: str) -> str:
"""The block device behind a path, with any btrfs subvolume stripped.
st_dev is not the question being asked. btrfs hands every subvolume its own
device number, so / and /home compare as different filesystems by that test
even though they are one pool with one free-space total -- which is the
exact confusion the filesystems list upstairs already exists to avoid. The
first version of the breakdown left the system-wide flatpak installation out
of the applications segment for that reason, and reported 4 kB of apps on a
machine with twenty gigabytes of them.
"""
if not shutil.which("findmnt"):
try:
return str(os.stat(path).st_dev)
except OSError:
return ""
try:
source = run(["findmnt", "-n", "-o", "SOURCE", "--target", path], timeout=10.0)
except BoundaryError:
return ""
return source.strip().split("[", 1)[0]
def same_filesystem(first: str, second: str) -> bool:
left = backing_device(first)
return left != "" and left == backing_device(second)
def breakdown() -> dict:
home = os.path.expanduser("~")
try:
usage = shutil.disk_usage(home)
except OSError as error:
raise BoundaryError("The filesystem holding your home folder could not be read.") from error
remaining = float(SCAN_TIMEOUT_SECONDS)
caches, remaining, caches_complete = measure_all([CACHE_TARGET], remaining)
home_targets = [target for _, target in SCAN_TARGETS if target != CACHE_TARGET]
home_bytes, remaining, home_complete = measure_all(home_targets, remaining)
# Only the installations that live on the same filesystem as home, because
# adding bytes from another drive into this drive's bar is a lie about this
# drive.
flatpak_paths = [target for target in FLATPAK_ROOTS
if Path(os.path.expanduser(target)).is_dir()
and same_filesystem(os.path.expanduser(target), home)]
applications, remaining, applications_complete = measure_all(flatpak_paths, remaining)
used = int(usage.used)
accounted = home_bytes + applications + caches
system = max(0, used - accounted)
return {
"segments": {
"home": home_bytes,
"applications": applications,
"caches": caches,
"system": system,
"free": int(usage.free),
},
"totalBytes": int(usage.total),
"usedBytes": used,
"freeBytes": int(usage.free),
# The measured segments are floors when this is false: something took
# longer than the budget and was left out rather than guessed at.
"complete": home_complete and caches_complete and applications_complete,
"exceedsUsed": accounted > used,
"path": home,
}
# ── Cleaning up, honestly ────────────────────────────────────────────────────
#
# Every row is itemized, sized in real bytes, and inert until its own id is
# passed to `clean`. There is no "clean everything" verb and there is no
# recommendation: the page shows what each one costs you -- caches are rebuilt,
# first launches get slower -- and lets it be somebody's decision.
def applications_helper() -> str:
return os.environ.get("PANAMA_APPLICATIONS_HELPER") or str(
Path(__file__).resolve().parent / "panama-applications")
def unused_runtime_bytes() -> int:
"""What the flatpak helper reports as unused, in bytes.
Asked of the applications helper rather than reimplemented, so the number
shown here and the thing `clean` removes can never come from two different
ideas of "unused".
"""
if not shutil.which("flatpak"):
return 0
try:
raw = run([applications_helper(), "unused-runtimes"], timeout=60.0)
entries = json.loads(raw)
except (BoundaryError, json.JSONDecodeError):
return 0
return sum(int(entry.get("sizeBytes") or 0)
for entry in entries if isinstance(entry, dict))
def dnf_package_cache_paths() -> list[str]:
paths = []
for root in DNF_CACHE_ROOTS:
directory = Path(root)
if not directory.is_dir():
continue
try:
paths.extend(str(child / "packages") for child in directory.iterdir()
if (child / "packages").is_dir())
except OSError:
continue
return paths
def cleanables() -> list[dict]:
remaining = float(SCAN_TIMEOUT_SECONDS)
cache_bytes, remaining, _ = measure_all([CACHE_TARGET], remaining)
trash_bytes, remaining, _ = measure_all([TRASH_TARGET], remaining)
dnf_bytes, remaining, _ = measure_all(dnf_package_cache_paths(), remaining)
return [
{
"id": "cache",
"label": "Application caches",
"detail": "~/.cache · rebuilt as apps run · first launches get slower once",
"bytes": cache_bytes,
"privileged": False,
},
{
"id": "trash",
"label": "Trash",
"detail": "Files you deleted · emptying is permanent",
"bytes": trash_bytes,
"privileged": False,
},
{
"id": "flatpak-unused",
"label": "Unused Flatpak runtimes",
"detail": "Runtimes no installed app asks for · flatpak decides the final list",
"bytes": unused_runtime_bytes(),
"privileged": False,
},
{
"id": "dnf-cache",
"label": "Package download cache",
"detail": "Downloaded packages · the system will ask for your password",
"bytes": dnf_bytes,
"privileged": True,
},
]
def guarded_cache_directory() -> Path:
"""~/.cache, or a refusal.
This function is the whole reason the cache row is safe to press. It refuses
a symlinked ~/.cache and refuses anything that resolves outside the home
directory, so XDG_CACHE_HOME pointing somewhere alarming, or a ~/.cache
someone linked to /, cannot turn one click into a deleted system.
"""
home = Path(os.path.expanduser("~")).resolve(strict=False)
target = Path(os.path.expanduser(CACHE_TARGET))
if target.is_symlink():
raise BoundaryError("The cache folder is a link, so it will not be emptied.")
if not target.is_dir():
raise BoundaryError("There is no cache folder to empty.")
resolved = target.resolve(strict=True)
if resolved == home or home not in resolved.parents:
raise BoundaryError("The cache folder is not inside your home folder.")
return resolved
def empty_cache() -> None:
"""Delete what is inside ~/.cache, never following a link out of it.
A cache file an application still has open cannot be removed, and that is
the normal case rather than a failure -- so a partial pass succeeds, and the
freshly measured size the caller gets back is what says how much is left.
Only a pass that removed nothing at all is reported as a failure.
"""
directory = guarded_cache_directory()
removed = 0
failures = 0
with os.scandir(directory) as entries:
for entry in entries:
try:
# is_symlink first: a symlinked directory must be unlinked, not
# walked, or this deletes whatever it points at.
if entry.is_symlink() or not entry.is_dir(follow_symlinks=False):
os.unlink(entry.path)
else:
# rmtree lstats as it goes and refuses to descend a symlink.
shutil.rmtree(entry.path, ignore_errors=False)
removed += 1
except OSError:
failures += 1
if failures and not removed:
raise BoundaryError("The cache is in use and nothing could be removed.")
def empty_trash() -> None:
if not shutil.which("gio"):
raise BoundaryError("gio is not available, so the trash cannot be emptied.")
# gio rather than removing ~/.local/share/Trash by hand: the trash is a
# freedesktop structure with per-file metadata and mount-point trash
# directories elsewhere, and gio empties all of it correctly.
run(["gio", "trash", "--empty"], timeout=300.0)
def clean_flatpak_unused() -> None:
if not shutil.which("flatpak"):
raise BoundaryError("Flatpak is not installed on this machine.")
run([applications_helper(), "clean-unused"], timeout=600.0)
def clean_dnf_cache() -> None:
if not shutil.which("dnf"):
raise BoundaryError("dnf is not available on this machine.")
# `clean packages` and never `clean all`: this drops the downloaded rpms,
# which is what was measured and what takes the space. Dropping the metadata
# as well would free little and make the next install slow for no reason.
#
# This is the only dnf invocation in Panama's settings surface, and it
# removes downloads. Nothing here removes an installed package.
try:
run(["pkexec", "dnf", "clean", "packages"], timeout=300.0)
except BoundaryError as error:
detail = str(error).lower()
if "dismissed" in detail or "not authorized" in detail:
raise BoundaryError("That change was not authorized.") from error
raise
CLEANERS = {
"cache": empty_cache,
"trash": empty_trash,
"flatpak-unused": clean_flatpak_unused,
"dnf-cache": clean_dnf_cache,
}
def clean(identifier: str) -> list[dict]:
"""Free exactly one named thing, and refuse everything else.
One id per call, no list, no "all". The caller has to name what it wants
removed, which is what keeps a mis-wired button from emptying four things.
"""
cleaner = CLEANERS.get(identifier or "")
if cleaner is None:
raise BoundaryError("There is nothing by that name to clean up.")
cleaner()
return cleanables()
def removable_device(path: str) -> dict:
"""Resolve a device path, refusing anything that is not removable.
@@ -354,6 +670,12 @@ def main(arguments: list[str]) -> int:
print(json.dumps(snapshot(), separators=(",", ":")))
elif arguments == ["scan"]:
print(json.dumps(scan(), separators=(",", ":")))
elif arguments == ["breakdown"]:
print(json.dumps(breakdown(), separators=(",", ":")))
elif arguments == ["cleanables"]:
print(json.dumps(cleanables(), separators=(",", ":")))
elif len(arguments) == 2 and arguments[0] == "clean":
print(json.dumps(clean(arguments[1]), separators=(",", ":")))
elif len(arguments) == 2 and arguments[0] in ("unmount", "eject"):
removable_device(arguments[1])
action = "unmount" if arguments[0] == "unmount" else "power-off"
@@ -361,7 +683,8 @@ def main(arguments: list[str]) -> int:
run(["udisksctl", action, flag, arguments[1]], timeout=30.0)
else:
raise BoundaryError(
"Usage: panama-disks snapshot | scan | unmount DEVICE | eject DEVICE")
"Usage: panama-disks snapshot | scan | breakdown | cleanables | "
"clean ID | unmount DEVICE | eject DEVICE")
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
+11 -1
View File
@@ -308,11 +308,21 @@ def delete(config: str, number: str) -> None:
raise BoundaryError(_refusal(result, "That snapshot could not be removed."))
# The three horizons the page can edit, and the largest number it will accept
# for any of them. 999 hourly snapshots is not a retention policy, it is a typo
# that fills a drive; the page offers a dropdown and this is its ceiling. The
# monthly and yearly limits are left exactly as snapper has them -- nothing here
# writes them, so a config with longer horizons keeps them.
RETENTION_LIMIT = 50
def set_retention(config: str, hourly: str, daily: str, weekly: str) -> None:
values = []
for label, value in (("HOURLY", hourly), ("DAILY", daily), ("WEEKLY", weekly)):
if not str(value).isdigit() or int(value) > 999:
if not str(value).isdigit():
raise BoundaryError("Keep counts must be whole numbers.")
if int(value) > RETENTION_LIMIT:
raise BoundaryError(f"Keep counts go up to {RETENTION_LIMIT}.")
values.append(f"TIMELINE_LIMIT_{label}={int(value)}")
result = run(["snapper", "-c", require_config(config), "set-config", *values])
if result.returncode != 0: