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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user