162 lines
4.0 KiB
Python
Executable File
162 lines
4.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
DMENU_ARGS = [
|
|
"dmenu",
|
|
"-i",
|
|
"-fn", "JetBrainsMono Nerd Font-10",
|
|
"-nb", "#121212",
|
|
"-nf", "#e0e0e0",
|
|
"-sb", "#222222",
|
|
"-sf", "#e0e0e0",
|
|
"-l", "10",
|
|
"-p", "Launch:",
|
|
]
|
|
|
|
DESKTOP_DIRS = [
|
|
Path.home() / ".local/share/applications",
|
|
Path.home() / ".local/share/flatpak/exports/share/applications",
|
|
Path("/var/lib/flatpak/exports/share/applications"),
|
|
Path("/usr/share/applications"),
|
|
Path("/usr/local/share/applications"),
|
|
]
|
|
|
|
INVALID_EXEC_PREFIXES = ("wine", "wscript", "rundll32", "winhlp32", "hh ")
|
|
|
|
|
|
def parse_desktop_file(path: Path):
|
|
"""
|
|
Returns (name, exec_cmd) or (None, None) if the file should be skipped.
|
|
Handles: NoDisplay=true, Hidden=true, missing Name/Exec, field codes, env wrappers.
|
|
"""
|
|
props = {}
|
|
in_desktop_entry = False
|
|
|
|
try:
|
|
with path.open(encoding="utf-8", errors="replace") as f:
|
|
for line in f:
|
|
line = line.rstrip("\n")
|
|
if line.startswith("["):
|
|
in_desktop_entry = line == "[Desktop Entry]"
|
|
continue
|
|
if not in_desktop_entry or "=" not in line:
|
|
continue
|
|
key, _, value = line.partition("=")
|
|
key = key.strip()
|
|
if key not in props:
|
|
props[key] = value.strip()
|
|
except OSError:
|
|
return None, None
|
|
|
|
if props.get("NoDisplay", "").lower() == "true":
|
|
return None, None
|
|
if props.get("Hidden", "").lower() == "true":
|
|
return None, None
|
|
if props.get("Type", "") != "Application":
|
|
return None, None
|
|
|
|
name = props.get("Name")
|
|
exec_raw = props.get("Exec")
|
|
|
|
if not name or not exec_raw:
|
|
return None, None
|
|
|
|
exec_cmd = clean_exec(exec_raw)
|
|
|
|
if not exec_cmd:
|
|
return None, None
|
|
|
|
lower = exec_cmd.lower()
|
|
if any(lower.startswith(p) for p in INVALID_EXEC_PREFIXES):
|
|
return None, None
|
|
|
|
terminal = props.get("Terminal", "").lower() == "true"
|
|
if terminal:
|
|
term = os.environ.get("TERMINAL", "xterm")
|
|
exec_cmd = f"{term} -e {exec_cmd}"
|
|
|
|
return name, exec_cmd
|
|
|
|
|
|
def clean_exec(exec_raw: str) -> str:
|
|
tokens = exec_raw.split()
|
|
cleaned = []
|
|
for token in tokens:
|
|
if token.startswith("%") and len(token) == 2 and token[1].isalpha():
|
|
continue
|
|
cleaned.append(token)
|
|
return " ".join(cleaned).strip()
|
|
|
|
|
|
def collect_apps():
|
|
seen_stems = {}
|
|
for directory in DESKTOP_DIRS:
|
|
if not directory.is_dir():
|
|
continue
|
|
for desktop_file in sorted(directory.glob("*.desktop")):
|
|
stem = desktop_file.stem.lower()
|
|
if stem not in seen_stems:
|
|
seen_stems[stem] = desktop_file
|
|
|
|
apps = {}
|
|
for desktop_file in seen_stems.values():
|
|
name, cmd = parse_desktop_file(desktop_file)
|
|
if name and cmd and name not in apps:
|
|
apps[name] = cmd
|
|
return apps
|
|
|
|
|
|
def run_dmenu(app_names: list[str]) -> str | None:
|
|
names_input = "\n".join(sorted(app_names, key=str.casefold)).encode()
|
|
try:
|
|
result = subprocess.run(
|
|
DMENU_ARGS,
|
|
input=names_input,
|
|
capture_output=True,
|
|
)
|
|
except FileNotFoundError:
|
|
sys.exit("error: dmenu not found in PATH")
|
|
|
|
if result.returncode != 0:
|
|
return None
|
|
|
|
return result.stdout.decode().strip()
|
|
|
|
|
|
def launch(cmd: str):
|
|
subprocess.Popen(
|
|
cmd,
|
|
shell=True,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
|
|
|
|
def main():
|
|
apps = collect_apps()
|
|
|
|
if not apps:
|
|
sys.exit("error: no applications found")
|
|
|
|
selected = run_dmenu(list(apps.keys()))
|
|
|
|
if not selected:
|
|
return
|
|
|
|
cmd = apps.get(selected)
|
|
if not cmd:
|
|
sys.exit(f"error: no command found for '{selected}'")
|
|
|
|
launch(cmd)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|