Current File : /home/inlingua/miniconda3/lib/python3.12/site-packages/menuinst/platforms/linux.py
""" """

import os
import shlex
import shutil
import time
from configparser import ConfigParser
from logging import getLogger
from pathlib import Path
from subprocess import CalledProcessError
from tempfile import TemporaryDirectory
from typing import Dict, Iterable, Tuple
from xml.etree import ElementTree

from ..utils import UnixLex, add_xml_child, indent_xml_tree, logged_run, unlink
from .base import Menu, MenuItem, menuitem_defaults

log = getLogger(__name__)


class LinuxMenu(Menu):
    """
    Menus in Linux are governed by the freedesktop.org standards,
    spec'd here https://specifications.freedesktop.org/menu-spec/menu-spec-latest.html

    menuinst will populate the relevant XML config and create a .directory entry
    """

    _system_config_directory = Path("/etc/xdg/")
    _system_data_directory = Path("/usr/share")

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.mode == "system":
            self.config_directory = self._system_config_directory
            self.data_directory = self._system_data_directory
        else:
            self.config_directory = Path(
                os.environ.get("XDG_CONFIG_HOME", "~/.config")
            ).expanduser()
            self.data_directory = Path(
                os.environ.get("XDG_DATA_HOME", "~/.local/share")
            ).expanduser()

        # XML Config paths
        self.system_menu_config_location = (
            self._system_config_directory / "menus" / "applications.menu"
        )
        self.menu_config_location = self.config_directory / "menus" / "applications.menu"
        # .desktop / .directory paths
        self.directory_entry_location = (
            self.data_directory
            / "desktop-directories"
            / f"{self.render(self.name, slug=True)}.directory"
        )
        self.desktop_entries_location = self.data_directory / "applications"

    def create(self) -> Tuple[os.PathLike]:
        self._ensure_directories_exist()
        path = self._write_directory_entry()
        if self._is_valid_menu_file() and self._has_this_menu():
            return (path,)
        self._ensure_menu_file()
        self._add_this_menu()
        return (path,)

    def remove(self) -> Tuple[os.PathLike]:
        for fn in os.listdir(self.desktop_entries_location):
            if fn.startswith(f"{self.render(self.name, slug=True)}_"):
                # found one shortcut, so don't remove the name from menu
                return (self.directory_entry_location,)
        unlink(self.directory_entry_location, missing_ok=True)
        self._remove_this_menu()
        return (self.directory_entry_location,)

    @property
    def placeholders(self) -> Dict[str, str]:
        placeholders = super().placeholders
        placeholders["SP_DIR"] = str(self._site_packages())
        return placeholders

    def _ensure_directories_exist(self):
        paths = [
            self.config_directory / "menus",
            self.data_directory / "desktop-directories",
            self.data_directory / "applications",
        ]
        for path in paths:
            log.debug("Ensuring path %s exists", path)
            path.mkdir(parents=True, exist_ok=True)

    #
    # .directory stuff methods
    #

    def _write_directory_entry(self) -> str:
        lines = [
            "[Desktop Entry]",
            "Type=Directory",
            "Encoding=UTF-8",
            f"Name={self.render(self.name)}",
        ]
        log.debug("Writing directory entry at %s", self.directory_entry_location)
        with open(self.directory_entry_location, "w") as f:
            f.write("\n".join(lines))

        return self.directory_entry_location

    #
    # XML config stuff methods
    #

    def _remove_this_menu(self):
        log.debug(
            "Editing %s to remove %s config", self.menu_config_location, self.render(self.name)
        )
        tree = ElementTree.parse(self.menu_config_location)
        root = tree.getroot()
        for elt in root.findall("Menu"):
            if elt.find("Name").text == self.render(self.name):
                root.remove(elt)
        self._write_menu_file(tree)

    def _has_this_menu(self) -> bool:
        root = ElementTree.parse(self.menu_config_location).getroot()
        return any(e.text == self.render(self.name) for e in root.findall("Menu/Name"))

    def _add_this_menu(self):
        log.debug("Editing %s to add %s config", self.menu_config_location, self.render(self.name))
        tree = ElementTree.parse(self.menu_config_location)
        root = tree.getroot()
        menu_elt = add_xml_child(root, "Menu")
        add_xml_child(menu_elt, "Name", self.render(self.name))
        add_xml_child(menu_elt, "Directory", f"{self.render(self.name, slug=True)}.directory")
        inc_elt = add_xml_child(menu_elt, "Include")
        add_xml_child(inc_elt, "Category", self.render(self.name))
        self._write_menu_file(tree)

    def _is_valid_menu_file(self) -> bool:
        try:
            root = ElementTree.parse(self.menu_config_location).getroot()
            return root is not None and root.tag == "Menu"
        except Exception:
            return False

    def _write_menu_file(self, tree: ElementTree):
        log.debug("Writing %s", self.menu_config_location)
        indent_xml_tree(tree.getroot())  # inplace!
        with open(self.menu_config_location, "wb") as f:
            f.write(b'<!DOCTYPE Menu PUBLIC "-//freedesktop//DTD Menu 1.0//EN"\n')
            f.write(b' "http://standards.freedesktop.org/menu-spec/menu-1.0.dtd">\n')
            tree.write(f)
            f.write(b"\n")

    def _ensure_menu_file(self):
        # ensure any existing version is a file
        if self.menu_config_location.exists() and not self.menu_config_location.is_file():
            raise RuntimeError(f"Menu config location {self.menu_config_location} is not a file!")
            # shutil.rmtree(self.menu_config_location)

        # ensure any existing file is actually a menu file
        if self.menu_config_location.is_file():
            # make a backup of the menu file to be edited
            cur_time = time.strftime("%Y-%m-%d_%Hh%Mm%S")
            backup_menu_file = f"{self.menu_config_location}.{cur_time}"
            shutil.copyfile(self.menu_config_location, backup_menu_file)

            if not self._is_valid_menu_file():
                os.remove(self.menu_config_location)
        else:
            self._new_menu_file()

    def _new_menu_file(self):
        log.debug("Creating %s", self.menu_config_location)
        with open(self.menu_config_location, "w") as f:
            f.write("<Menu><Name>Applications</Name>")
            if self.mode == "user":
                f.write(f'<MergeFile type="parent">{self.system_menu_config_location}</MergeFile>')
            f.write("</Menu>\n")

    def _paths(self) -> Tuple[str]:
        return (self.directory_entry_location,)


class LinuxMenuItem(MenuItem):
    @property
    def location(self) -> Path:
        menu_prefix = self.render(self.menu.name, slug=True, extra={})
        # TODO: filename should conform to D-Bus well known name conventions
        # https://specifications.freedesktop.org/desktop-entry-spec/latest/ar01s02.html
        filename = f"{menu_prefix}_{self.render_key('name', slug=True, extra={})}.desktop"
        return self.menu.desktop_entries_location / filename

    def create(self) -> Iterable[os.PathLike]:
        log.debug("Creating %s", self.location)
        self._precreate()
        self._write_desktop_file()
        self._maybe_register_mime_types(register=True)
        self._update_desktop_database()
        return self._paths()

    def remove(self) -> Iterable[os.PathLike]:
        paths = self._paths()
        self._maybe_register_mime_types(register=False)
        for path in paths:
            log.debug("Removing %s", path)
            unlink(path, missing_ok=True)
        self._update_desktop_database()
        return paths

    def _update_desktop_database(self):
        exe = shutil.which("update-desktop-database")
        if exe:
            logged_run(
                [exe, str(self.menu.desktop_entries_location)],
                check=False,
            )

    def _command(self) -> str:
        parts = []
        precommand = self.render_key("precommand")
        if precommand:
            parts.append(precommand)
        if self.metadata["activate"]:
            conda_exe = self.menu.conda_exe
            if self.menu._is_micromamba(conda_exe):
                activate = "shell activate"
            else:
                activate = "shell.bash activate"
            parts.append(f'eval "$("{conda_exe}" {activate} "{self.menu.prefix}")"')
        parts.append(" ".join(UnixLex.quote_args(self.render_key("command"))))
        return "bash -c " + shlex.quote(" && ".join(parts))

    def _write_desktop_file(self):
        if self.location.exists():
            log.warning("Overwriting existing file at %s.", self.location)

        lines = [
            "[Desktop Entry]",
            "Type=Application",
            "Encoding=UTF-8",
            f'Name={self.render_key("name")}',
            f"Exec={self._command()}",
            f'Terminal={str(self.render_key("terminal")).lower()}',
        ]

        icon = self.render_key("icon")
        if icon:
            lines.append(f'Icon={self.render_key("icon")}')

        description = self.render_key("description")
        if description:
            lines.append(f'Comment={self.render_key("description")}')

        working_dir = self.render_key("working_dir")
        if working_dir:
            Path(os.path.expandvars(working_dir)).mkdir(parents=True, exist_ok=True)
            lines.append(f"Path={working_dir}")

        for key in menuitem_defaults["platforms"]["linux"]:
            if key in (*menuitem_defaults, "glob_patterns"):
                continue
            value = self.render_key(key)
            if value is None:
                continue
            if isinstance(value, bool):
                value = str(value).lower()
            elif isinstance(value, (list, tuple)):
                value = ";".join(value) + ";"
            lines.append(f"{key}={value}")

        with open(self.location, "w") as f:
            f.write("\n".join(lines))
            f.write("\n")

    def _maybe_register_mime_types(self, register=True):
        mime_types = self.render_key("MimeType")
        if not mime_types:
            return
        self._register_mime_types(mime_types, register=register)

    def _register_mime_types(self, mime_types: Iterable[str], register: bool = True):
        glob_patterns = self.render_key("glob_patterns") or {}
        for mime_type in mime_types:
            glob_pattern = glob_patterns.get(mime_type)
            if glob_pattern:
                self._glob_pattern_for_mime_type(mime_type, glob_pattern, install=register)

        mimeapps = self.menu.config_directory / "mimeapps.list"
        if register:
            config = ConfigParser(default_section=None)
            if mimeapps.is_file():
                config.read(mimeapps)
            log.debug("Registering %s to %s...", mime_types, mimeapps)
            if "Default Applications" not in config.sections():
                config.add_section("Default Applications")
            if "Added Associations" not in config.sections():
                config.add_section("Added Associations")
            defaults = config["Default Applications"]
            added = config["Added Associations"]
            for mime_type in mime_types:
                if mime_type not in defaults:
                    # Do not override existing defaults
                    defaults[mime_type] = self.location.name
                if mime_type in added and self.location.name not in added[mime_type]:
                    added[mime_type] = f"{added[mime_type]};{self.location.name}"
                else:
                    added[mime_type] = self.location.name
            with open(mimeapps, "w") as f:
                config.write(f, space_around_delimiters=False)
        elif mimeapps.is_file():
            # Remove entries
            config = ConfigParser(default_section=None)
            config.read(mimeapps)
            log.debug("Deregistering %s from %s...", mime_types, mimeapps)
            for section_name in "Default Applications", "Added Associations":
                if section_name not in config.sections():
                    continue
                section = config[section_name]
                for mimetype, desktop_files in section.items():
                    if self.location.name == desktop_files:
                        section.pop(mimetype)
                    elif self.location.name in desktop_files.split(";"):
                        section[mimetype] = ";".join(
                            [x for x in desktop_files.split(";") if x != self.location.name]
                        )
                if not section.keys():
                    config.remove_section(section_name)
            with open(mimeapps, "w") as f:
                config.write(f, space_around_delimiters=False)

        update_mime_database = shutil.which("update-mime-database")
        if update_mime_database:
            logged_run(
                [update_mime_database, "-V", self.menu.data_directory / "mime"],
                check=False,
            )

    def _xml_path_for_mime_type(self, mime_type: str) -> Tuple[Path, bool]:
        basename = mime_type.replace("/", "-")
        xml_files = list(
            (self.menu.data_directory / "mime" / "applications").glob(f"*{basename}*.xml")
        )
        if xml_files:
            if len(xml_files) > 1:
                msg = "Found multiple files for MIME type %s: %s. Returning first."
                log.debug(msg, mime_type, xml_files)
            return xml_files[0], True
        return self.menu.data_directory / "mime" / "packages" / f"{basename}.xml", False

    def _glob_pattern_for_mime_type(
        self,
        mime_type: str,
        glob_pattern: str,
        install: bool = True,
    ) -> Path:
        """
        See https://specifications.freedesktop.org/mime-apps-spec/mime-apps-spec-latest.html
        for more information on the default locations.
        """
        xml_path, exists = self._xml_path_for_mime_type(mime_type)
        if exists:
            return xml_path

        # Write the XML that binds our current mime type to the glob pattern
        xmlns = "http://www.freedesktop.org/standards/shared-mime-info"
        mime_info = ElementTree.Element("mime-info", xmlns=xmlns)
        mime_type_tag = ElementTree.SubElement(mime_info, "mime-type", type=mime_type)
        ElementTree.SubElement(mime_type_tag, "glob", pattern=glob_pattern)
        descr = f"Custom MIME type {mime_type} for '{glob_pattern}' files (registered by menuinst)"
        ElementTree.SubElement(mime_type_tag, "comment").text = descr
        tree = ElementTree.ElementTree(mime_info)

        subcommand = "install" if install else "uninstall"
        # Install the XML file and register it as default for our app
        try:
            with TemporaryDirectory() as tmp:
                with open(os.path.join(tmp, os.path.basename(xml_path)), "wb") as f:
                    tree.write(f, encoding="UTF-8", xml_declaration=True)
                logged_run(
                    ["xdg-mime", subcommand, "--mode", self.menu.mode, "--novendor", f.name],
                    check=True,
                )
        except CalledProcessError:
            log.debug(
                "Could not un/register MIME type %s with xdg-mime. Writing to '%s' as a fallback.",
                mime_type,
                xml_path,
            )
            tree.write(xml_path, encoding="UTF-8", xml_declaration=True)

    def _paths(self) -> Iterable[os.PathLike]:
        paths = [self.location]
        mime_types = self.render_key("MimeType") or ()
        for mime in mime_types:
            xml_path, exists = self._xml_path_for_mime_type(mime)
            if exists and "registered by menuinst" in xml_path.read_text():
                paths.append(xml_path)
        return tuple(paths)