Files

648 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read and update freedesktop defaults for Panama's settings page."""
from __future__ import annotations
import ast
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
# A role owns a FAMILY of types, not one representative.
#
# Each role used to carry a single mime type, so setting "images" changed
# image/png and left image/jpeg wherever it happened to land. That is exactly
# how this machine ended up opening PNGs in a pixel-art editor, MP3s in a video
# transcoder and PDFs in GIMP: nobody chose any of it, the applications
# registered themselves and the roles only ever governed one type each.
#
# The FIRST entry in each list is the one queried when reporting the current
# handler; all of them are written when the role is set, so a family cannot
# drift apart again.
ROLE_TARGETS = {
"browser": ("settings", ["default-web-browser"]),
"mail": ("mime", ["x-scheme-handler/mailto"]),
"files": ("mime", ["inode/directory"]),
"terminal": ("mime", ["x-scheme-handler/terminal"]),
"music": ("mime", [
"audio/mpeg", "audio/flac", "audio/x-vorbis+ogg", "audio/ogg",
"audio/x-wav", "audio/mp4", "audio/aac", "audio/opus",
]),
"images": ("mime", [
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/tiff", "image/bmp", "image/svg+xml", "image/avif",
]),
"video": ("mime", [
"video/mp4", "video/x-matroska", "video/webm", "video/quicktime",
"video/x-msvideo", "video/mpeg",
]),
"documents": ("mime", [
"application/pdf", "application/epub+zip",
]),
"text": ("mime", [
"text/plain", "text/markdown", "text/x-python", "text/x-csrc",
"text/x-chdr", "text/x-c++src", "text/x-shellscript",
"application/json", "application/x-yaml", "text/xml",
]),
"archives": ("mime", [
"application/zip", "application/x-tar", "application/gzip",
"application/x-7z-compressed", "application/vnd.rar",
]),
}
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):
"""A user-visible validation or command failure."""
def xdg_data_roots() -> list[Path]:
data_home = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share"))
data_dirs = os.environ.get("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
return [data_home, *(Path(item) for item in data_dirs.split(":") if item)]
def discovered_desktop_files() -> dict[str, Path]:
desktop_files: dict[str, Path] = {}
for root in xdg_data_roots():
applications = root / "applications"
if not applications.is_dir():
continue
for path in applications.rglob("*.desktop"):
if not path.is_file():
continue
relative = path.relative_to(applications)
desktop_files.setdefault("-".join(relative.parts), path)
return desktop_files
def discovered_desktop_ids() -> set[str]:
return set(discovered_desktop_files())
def require_desktop_id(desktop_id: str, *, discovered: set[str]) -> None:
if not DESKTOP_ID.fullmatch(desktop_id) or desktop_id not in discovered:
raise BoundaryError("That application is not available.")
def run(command: list[str]) -> str:
completed = subprocess.run(command, check=False, capture_output=True, text=True)
if completed.returncode != 0:
detail = completed.stderr.strip()
raise BoundaryError(detail or "The system default could not be updated.")
return completed.stdout.strip()
def query_handlers() -> dict[str, str]:
handlers: dict[str, str] = {}
for role, (kind, targets) in ROLE_TARGETS.items():
# The first type represents the family when reporting.
target = targets[0]
command = (
["xdg-settings", "get", target]
if kind == "settings"
else ["xdg-mime", "query", "default", target]
)
output = run(command)
handlers[role] = output.splitlines()[0] if output else ""
return handlers
def parse_desktop_entry(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as error:
raise BoundaryError(f"Could not read {path.name}.") from error
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Desktop Entry" or "=" not in line or stripped.startswith("#"):
continue
key, value = line.split("=", 1)
values.setdefault(key.strip(), value.strip())
return values
def autostart_directory() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "autostart"
def user_autostart_entries() -> list[dict[str, object]]:
directory = autostart_directory()
if not directory.is_dir():
return []
entries: list[dict[str, object]] = []
for path in directory.glob("*.desktop"):
if path.is_symlink() or not path.is_file():
continue
values = parse_desktop_entry(path)
entries.append(
{
"id": path.name,
"name": values.get("Name", path.stem),
"enabled": values.get("Hidden", "false").lower() != "true",
}
)
return sorted(entries, key=lambda entry: (str(entry["name"]).casefold(), str(entry["id"])))
def hypr_autostart_path() -> Path:
override = os.environ.get("PANAMA_HYPR_AUTOSTART")
if override:
return Path(override)
return Path(__file__).resolve().parents[2] / "hypr" / "autostart.lua"
def lua_autostart_entries() -> list[dict[str, object]]:
path = hypr_autostart_path()
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return []
commands: list[str] = []
in_start_handler = False
for line in lines:
if not in_start_handler:
in_start_handler = bool(re.search(r'hl\.on\(\s*"hyprland\.start"', line))
continue
if line.strip() == "end)":
break
match = EXEC_CMD.search(line)
if match:
try:
commands.append(ast.literal_eval(match.group(1)))
except (SyntaxError, ValueError):
continue
return [
{
"id": f"hyprland:{index}",
"name": command.split()[0].rsplit("/", 1)[-1],
"command": command,
"enabled": True,
"readOnly": True,
"source": "config/dot/hypr/autostart.lua",
}
for index, command in enumerate(commands, start=1)
]
def snapshot() -> dict[str, object]:
return {
"handlers": query_handlers(),
"autostartEntries": user_autostart_entries(),
"luaAutostartEntries": lua_autostart_entries(),
}
def set_default(role: str, desktop_id: str) -> None:
target = ROLE_TARGETS.get(role)
if target is None:
raise BoundaryError("That default application role is not supported.")
require_desktop_id(desktop_id, discovered=discovered_desktop_ids())
kind, settings = target
if kind == "settings":
run(["xdg-settings", "set", settings[0], desktop_id])
return
# Every type in the family, so a role cannot be half-applied. xdg-mime
# accepts several types in one call, but they are written individually so a
# type this system does not know about cannot fail the whole role.
for setting in settings:
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
# an unattended machine decides these by installation order: a pixel-art editor
# claims PNG, a video transcoder claims MP3, an image editor claims PDF. None of
# that is a choice anyone made, and it is only discovered by double-clicking.
#
# Each role lists candidates best-first; the first one installed wins. A role
# with no candidate installed is left alone rather than forced.
PREFERRED_HANDLERS = {
"images": ["org.gnome.Loupe.desktop", "org.gnome.eog.desktop"],
"music": ["org.gnome.Decibels.desktop", "io.bassi.Amberol.desktop", "io.mpv.Mpv.desktop"],
"video": ["io.mpv.Mpv.desktop", "mpv.desktop", "org.gnome.Totem.desktop"],
"documents": ["org.gnome.Papers.desktop", "org.gnome.Evince.desktop"],
"text": ["panama-nvim.desktop"],
"archives": ["org.gnome.Nautilus.desktop", "org.gnome.FileRoller.desktop"],
}
def user_mimeapps() -> Path:
config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return config_home / "mimeapps.list"
def chosen_types() -> set[str]:
"""Types the person using this machine has already assigned by hand.
A type listed under [Default Applications] in the user's own mimeapps.list
got there because someone picked "Open With" and made it stick, or because
the settings page wrote it. Seeding must never overrule that.
"""
path = user_mimeapps()
if not path.is_file():
return set()
chosen: set[str] = set()
section = ""
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError):
return set()
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
section = stripped[1:-1]
continue
if section != "Default Applications" or "=" not in line or stripped.startswith("#"):
continue
chosen.add(line.split("=", 1)[0].strip())
return chosen
def seed() -> None:
"""Apply Panama's curated defaults to roles nobody has chosen for."""
discovered = discovered_desktop_ids()
already = chosen_types()
for role, candidates in PREFERRED_HANDLERS.items():
kind, targets = ROLE_TARGETS[role]
if kind != "mime":
continue
if any(target in already for target in targets):
print(f"{role}: keeping the existing choice")
continue
preferred = next((entry for entry in candidates if entry in discovered), None)
if preferred is None:
print(f"{role}: no preferred application installed, leaving it alone")
continue
set_default(role, preferred)
print(f"{role}: {preferred}")
def with_hidden(original: str, *, hidden: bool) -> str:
lines = original.splitlines()
output: list[str] = []
section = ""
found_section = False
wrote_hidden = False
for line in lines:
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
if section == "Desktop Entry" and not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
section = stripped[1:-1]
found_section = found_section or section == "Desktop Entry"
output.append(line)
continue
if section == "Desktop Entry" and line.split("=", 1)[0].strip() == "Hidden":
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
wrote_hidden = True
continue
output.append(line)
if not found_section:
raise BoundaryError("That autostart entry is not a desktop file.")
if not wrote_hidden:
output.append(f"Hidden={'true' if hidden else 'false'}")
return "\n".join(output) + "\n"
def write_atomic(path: Path, text: str, *, mode: int) -> None:
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
) as temporary:
temporary.write(text)
temporary.flush()
os.fsync(temporary.fileno())
temporary_path = Path(temporary.name)
temporary_path.chmod(mode)
os.replace(temporary_path, path)
except OSError as error:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise BoundaryError("That autostart entry could not be updated.") from error
def update_hidden(path: Path, *, hidden: bool) -> None:
try:
original = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That autostart entry could not be read.") from error
mode = path.stat().st_mode
write_atomic(path, with_hidden(original, hidden=hidden), mode=mode)
def remove_autostart(desktop_id: str) -> None:
"""Delete a user autostart entry.
Disabling writes Hidden=true and is reversible; this is not, so it is
confined to files this directory owns. A symlink is refused rather than
followed, because deleting through one would remove whatever it points at --
which is somewhere else entirely, and not ours.
"""
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That is not an autostart entry name.")
directory = autostart_directory()
target = directory / desktop_id
# Resolved and compared, so a name like "../../.bashrc" cannot escape.
try:
resolved = target.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry no longer exists.") from error
if resolved.parent != directory.resolve(strict=False):
raise BoundaryError("That autostart entry is not in the autostart directory.")
if target.is_symlink() or not target.is_file():
raise BoundaryError("That autostart entry is not a file this can remove.")
if target.suffix != ".desktop":
raise BoundaryError("That autostart entry is not a desktop file.")
try:
target.unlink()
except OSError as error:
raise BoundaryError("That autostart entry could not be removed.") from error
def add_autostart(desktop_id: str) -> None:
desktop_files = discovered_desktop_files()
require_desktop_id(desktop_id, discovered=set(desktop_files))
source = desktop_files[desktop_id]
directory = autostart_directory()
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as error:
raise BoundaryError("The user autostart directory could not be created.") from error
target = directory / desktop_id
if target.is_symlink():
raise BoundaryError("That autostart entry is not available.")
if target.exists():
if not target.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(target, hidden=False)
return
try:
original = source.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise BoundaryError("That application could not be read.") from error
write_atomic(target, with_hidden(original, hidden=False), mode=0o644)
def set_autostart(desktop_id: str, enabled_text: str) -> None:
if enabled_text not in {"true", "false"}:
raise BoundaryError("Autostart state must be true or false.")
if not DESKTOP_ID.fullmatch(desktop_id):
raise BoundaryError("That autostart entry is not available.")
directory = autostart_directory()
path = directory / desktop_id
try:
resolved_directory = directory.resolve(strict=True)
resolved_path = path.resolve(strict=True)
except OSError as error:
raise BoundaryError("That autostart entry is not available.") from error
if path.is_symlink() or resolved_path.parent != resolved_directory or not resolved_path.is_file():
raise BoundaryError("That autostart entry is not available.")
update_hidden(resolved_path, hidden=enabled_text == "false")
def main(arguments: list[str]) -> int:
try:
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
elif arguments == ["seed"]:
seed()
elif len(arguments) == 3 and arguments[0] == "set-default":
set_default(arguments[1], arguments[2])
elif len(arguments) == 3 and arguments[0] == "set-autostart":
set_autostart(arguments[1], arguments[2])
elif len(arguments) == 2 and arguments[0] == "remove-autostart":
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 | "
"remove-autostart DESKTOP_ID | search-types QUERY | set-type MIME DESKTOP_ID"
)
except BoundaryError as error:
print(str(error), file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))