Add application and autostart settings
This commit is contained in:
+275
@@ -0,0 +1,275 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user