Source code for pbpicat.platform._linux

"""Linux (XDG) file-open helpers."""

from __future__ import annotations

import mimetypes
import os
import shlex
import subprocess
from pathlib import Path

from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
    QDialog,
    QDialogButtonBox,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QVBoxLayout,
)

_XDG_APP_DIRS: list[Path] = [
    Path.home() / ".local/share/applications",
    Path("/usr/share/applications"),
    Path("/usr/local/share/applications"),
]

# Run subprocesses with English output to avoid locale-dependent parsing.
_C_ENV = {**os.environ, "LANG": "C", "LC_ALL": "C"}


[docs] def config_dir() -> Path: return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "pbpicat"
[docs] def open_default(path: Path) -> None: subprocess.Popen(["xdg-open", str(path)])
[docs] def open_with(path: Path, parent=None) -> None: apps = _apps_for_path(path) dialog = _AppChooserDialog(apps, parent) if dialog.exec() != QDialog.Accepted: return desktop_file = dialog.selected_desktop_file() if desktop_file: _launch(desktop_file, path)
# --------------------------------------------------------------------------- # MIME type # --------------------------------------------------------------------------- def _mime_type(path: Path) -> str: try: result = subprocess.run( ["xdg-mime", "query", "filetype", str(path)], capture_output=True, text=True, timeout=3, env=_C_ENV, ) if result.returncode == 0: mime = result.stdout.strip() if mime: return mime except (FileNotFoundError, subprocess.TimeoutExpired, OSError): pass mime, _ = mimetypes.guess_type(str(path)) return mime or "" # --------------------------------------------------------------------------- # App list # --------------------------------------------------------------------------- def _apps_for_path(path: Path) -> list[tuple[str, str]]: mime = _mime_type(path) desktop_files = _registered_apps(mime) if mime else [] seen: set[str] = set() apps: list[tuple[str, str]] = [] for df in desktop_files: if df in seen: continue seen.add(df) name = _desktop_name(df) or df.removesuffix(".desktop") apps.append((name, df)) return apps def _registered_apps(mime: str) -> list[str]: # Try gio first (LANG=C avoids locale-dependent section headers). try: result = subprocess.run( ["gio", "mime", mime], capture_output=True, text=True, timeout=3, env=_C_ENV, ) if result.returncode == 0: apps = _parse_gio_output(result.stdout) if apps: return apps except (FileNotFoundError, subprocess.TimeoutExpired, OSError): pass return _scan_desktop_dirs(mime) def _parse_gio_output(output: str) -> list[str]: """Extract .desktop file names from 'gio mime' output.""" apps: list[str] = [] in_registered = False for line in output.splitlines(): s = line.strip() if s == "Registered applications:": in_registered = True elif in_registered: if s.endswith(".desktop"): apps.append(s) elif s: break # reached "Default application:" or next section return apps def _scan_desktop_dirs(mime: str) -> list[str]: """Fallback: scan .desktop files for matching MimeType entry.""" apps: list[str] = [] seen: set[str] = set() for d in _XDG_APP_DIRS: if not d.is_dir(): continue for fp in d.glob("*.desktop"): if fp.name in seen: continue try: for line in fp.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("MimeType="): if mime in line[9:].split(";"): apps.append(fp.name) seen.add(fp.name) break except OSError: pass return apps def _desktop_name(desktop_file: str) -> str: for d in _XDG_APP_DIRS: fp = d / desktop_file if not fp.is_file(): continue try: for line in fp.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("Name="): return line[5:].strip() except OSError: pass return "" # --------------------------------------------------------------------------- # Launch # --------------------------------------------------------------------------- def _launch(desktop_file: str, path: Path) -> None: try: subprocess.Popen(["gtk-launch", desktop_file, str(path)]) return except FileNotFoundError: pass _exec_via_desktop(desktop_file, path) def _exec_via_desktop(desktop_file: str, path: Path) -> None: for d in _XDG_APP_DIRS: fp = d / desktop_file if not fp.is_file(): continue try: for line in fp.read_text(encoding="utf-8", errors="replace").splitlines(): if line.startswith("Exec="): cmd = _build_cmd(line[5:].strip(), path) subprocess.Popen(cmd) return except OSError: pass def _build_cmd(exec_val: str, path: Path) -> list[str]: parts = shlex.split(exec_val) result: list[str] = [] for part in parts: if part in ("%f", "%F", "%u", "%U"): result.append(str(path)) elif part.startswith("%"): pass else: result.append(part) if str(path) not in result: result.append(str(path)) return result # --------------------------------------------------------------------------- # Dialog # --------------------------------------------------------------------------- class _AppChooserDialog(QDialog): def __init__(self, apps: list[tuple[str, str]], parent=None): super().__init__(parent) self.setWindowTitle(_("Open with")) self.setMinimumWidth(320) layout = QVBoxLayout(self) layout.addWidget(QLabel(_("Choose an application:"))) self._list = QListWidget() for name, desktop_file in apps: item = QListWidgetItem(name) item.setData(Qt.UserRole, desktop_file) self._list.addItem(item) if self._list.count(): self._list.setCurrentRow(0) self._list.itemActivated.connect(self._on_activate) layout.addWidget(self._list) buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _on_activate(self, _item) -> None: self.accept() def selected_desktop_file(self) -> str | None: item = self._list.currentItem() return item.data(Qt.UserRole) if item else None