276 lines
9.3 KiB
Python
Executable File
276 lines
9.3 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
|
|
|
|
|
|
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"),
|
|
"images": ("mime", "image/png"),
|
|
"video": ("mime", "video/mp4"),
|
|
}
|
|
DESKTOP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*\.desktop$")
|
|
EXEC_CMD = re.compile(r"hl\.exec_cmd\(\s*(\"(?:\\.|[^\"\\])*\")\s*\)")
|
|
|
|
|
|
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_ids() -> set[str]:
|
|
desktop_ids: set[str] = set()
|
|
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_ids.add("-".join(relative.parts))
|
|
return desktop_ids
|
|
|
|
|
|
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, target) in ROLE_TARGETS.items():
|
|
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, setting = target
|
|
command = (
|
|
["xdg-settings", "set", setting, desktop_id]
|
|
if kind == "settings"
|
|
else ["xdg-mime", "default", desktop_id, setting]
|
|
)
|
|
run(command)
|
|
|
|
|
|
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
|
|
|
|
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'}")
|
|
|
|
mode = path.stat().st_mode
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
|
) as temporary:
|
|
temporary.write("\n".join(output) + "\n")
|
|
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" in locals():
|
|
temporary_path.unlink(missing_ok=True)
|
|
raise BoundaryError("That autostart entry could not be updated.") from error
|
|
|
|
|
|
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 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])
|
|
else:
|
|
raise BoundaryError(
|
|
"Usage: panama-default-apps snapshot | set-default ROLE DESKTOP_ID | "
|
|
"set-autostart DESKTOP_ID true|false"
|
|
)
|
|
except BoundaryError as error:
|
|
print(str(error), file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|