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
@@ -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)