""" """ import os import shutil import warnings from logging import getLogger from pathlib import Path from subprocess import CompletedProcess from tempfile import NamedTemporaryFile from typing import Any, Dict, Optional, Tuple from ..utils import WinLex, logged_run, unlink from .base import Menu, MenuItem from .win_utils.knownfolders import folder_path as windows_folder_path from .win_utils.registry import ( register_file_extension, register_url_protocol, unregister_file_extension, unregister_url_protocol, ) log = getLogger(__name__) class WindowsMenu(Menu): def create(self) -> Tuple[os.PathLike]: log.debug("Creating %s", self.start_menu_location) self.start_menu_location.mkdir(parents=True, exist_ok=True) if self.quick_launch_location: self.quick_launch_location.mkdir(parents=True, exist_ok=True) if self.desktop_location: self.desktop_location.mkdir(parents=True, exist_ok=True) return (self.start_menu_location,) def remove(self) -> Tuple[os.PathLike]: log.debug("Removing %s", self.start_menu_location) shutil.rmtree(self.start_menu_location, ignore_errors=True) return (self.start_menu_location,) @property def start_menu_location(self) -> Path: """ On Windows we can create shortcuts in several places: - Start Menu - Desktop - Quick launch (only for user installs) In this property we only report the path to the Start menu. For other menus, check their respective properties. """ return Path(windows_folder_path(self.mode, False, "start")) / self.render(self.name) @property def quick_launch_location(self) -> Path: if self.mode == "system": # TODO: Check if this is true? warnings.warn("Quick launch menus are not available for system level installs") return return Path(windows_folder_path(self.mode, False, "quicklaunch")) @property def desktop_location(self) -> Path: return Path(windows_folder_path(self.mode, False, "desktop")) @property def placeholders(self) -> Dict[str, str]: placeholders = super().placeholders placeholders.update( { "SCRIPTS_DIR": str(self.prefix / "Scripts"), "PYTHON": str(self.prefix / "python.exe"), "PYTHONW": str(self.prefix / "pythonw.exe"), "BASE_PYTHON": str(self.base_prefix / "python.exe"), "BASE_PYTHONW": str(self.base_prefix / "pythonw.exe"), "BIN_DIR": str(self.prefix / "Library" / "bin"), "SP_DIR": str(self._site_packages()), "ICON_EXT": "ico", } ) return placeholders def _conda_exe_path_candidates(self): return ( self.base_prefix / "_conda.exe", Path(os.environ.get("CONDA_EXE", r"C:\oops\a_file_that_does_not_exist")), self.base_prefix / "condabin" / "conda.bat", self.base_prefix / "bin" / "conda.bat", Path(os.environ.get("MAMBA_EXE", r"C:\oops\a_file_that_does_not_exist")), self.base_prefix / "condabin" / "micromamba.exe", self.base_prefix / "bin" / "micromamba.exe", ) def render( self, value: Any, slug: bool = False, extra: Optional[Dict[str, str]] = None ) -> Any: """ We extend the render method here to replace forward slashes with backslashes. We ONLY do it if the string does not start with /, because it might be just a windows-style command-line flag (e.g. cmd /K something). This will not escape strings such as `/flag:something`, common in compiler stuff but we can assume such windows specific constructs will have their platform override, which is always an option. This is just a convenience for things like icon paths or Unix-like paths in the command key. """ value = super().render(value, slug=slug, extra=extra) if hasattr(value, "replace") and "/" in value and value[0] != "/": value = value.replace("/", "\\") return value def _site_packages(self, prefix=None) -> Path: if prefix is None: prefix = self.prefix return self.prefix / "Lib" / "site-packages" def _paths(self) -> Tuple[Path]: return (self.start_menu_location,) class WindowsMenuItem(MenuItem): @property def location(self) -> Path: """ Path to the .lnk file placed in the Start Menu On Windows, menuinst can create up to three shortcuts (start menu, desktop, quick launch) This property only lists the one for start menu """ return self.menu.start_menu_location / self._shortcut_filename() def create(self) -> Tuple[Path, ...]: from .win_utils.winshortcut import create_shortcut self._precreate() paths = self._paths() for path in paths: if not path.suffix == ".lnk": continue target_path, *arguments = self._process_command() working_dir = self.render_key("working_dir") if working_dir: Path(working_dir).mkdir(parents=True, exist_ok=True) else: working_dir = "%HOMEPATH%" icon = self.render_key("icon") or "" # winshortcut is a windows-only C extension! create_shortcut has this API # Notice args must be passed as positional, no keywords allowed! # winshortcut.create_shortcut(path, description, filename, arguments="", # workdir=None, iconpath=None, iconindex=0, app_id="") create_shortcut( target_path, self._shortcut_filename(ext=""), str(path), " ".join(arguments), working_dir, icon, 0, self._app_user_model_id(), ) self._register_file_extensions() self._register_url_protocols() return paths def remove(self) -> Tuple[Path, ...]: self._unregister_file_extensions() self._unregister_url_protocols() paths = self._paths() for path in paths: log.debug("Removing %s", path) unlink(path, missing_ok=True) return paths def _paths(self) -> Tuple[Path, ...]: paths = [self.location] extra_dirs = [] if self.metadata["desktop"]: extra_dirs.append(self.menu.desktop_location) if self.metadata["quicklaunch"] and self.menu.quick_launch_location: extra_dirs.append(self.menu.quick_launch_location) if extra_dirs: paths += [directory / self._shortcut_filename() for directory in extra_dirs] if self.metadata["activate"]: # This is the accessory launcher script for cmd paths.append(self._path_for_script()) return tuple(paths) def _shortcut_filename(self, ext: str = "lnk"): env_suffix = f" ({self.menu.env_name})" if self.menu.env_name else "" ext = f".{ext}" if ext else "" return f"{self.render_key('name', extra={})}{env_suffix}{ext}" def _path_for_script(self) -> Path: return Path(self.menu.placeholders["MENU_DIR"]) / self._shortcut_filename("bat") def _precreate(self): precreate_code = self.render_key("precreate") if not precreate_code: return with NamedTemporaryFile(delete=False, mode="w") as tmp: tmp.write(precreate_code) system32 = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "system32" cmd = [ str(system32 / "WindowsPowerShell" / "v1.0" / "powershell.exe"), f"\"start '{tmp.name}' -WindowStyle hidden\"", ] logged_run(cmd, check=True) os.unlink(tmp.name) def _command(self) -> str: lines = [ "@ECHO OFF", ":: Script generated by conda/menuinst", ] precommand = self.render_key("precommand") if precommand: lines.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.cmd.exe activate" activator = f'{self.menu.conda_exe} {activate} "{self.menu.prefix}"' lines += [ "@SETLOCAL ENABLEDELAYEDEXPANSION", f'@FOR /F "usebackq tokens=*" %%i IN (`{activator}`) do set "ACTIVATOR=%%i"', "@CALL %ACTIVATOR%", ":: This below is the user command", ] lines.append(" ".join(WinLex.quote_args(self.render_key("command")))) return "\r\n".join(lines) def _write_script(self, script_path: Optional[os.PathLike] = None) -> Path: """ This method generates the batch script that will be called by the shortcut """ if script_path is None: script_path = self._path_for_script() else: script_path = Path(script_path) script_path.parent.mkdir(parents=True, exist_ok=True) with open(script_path, "w") as f: f.write(self._command()) return script_path def _process_command(self, with_arg1=False) -> Tuple[str]: if self.metadata["activate"]: script = self._write_script() if self.metadata["terminal"]: command = ["cmd", "/D", "/K", f'"{script}"'] if with_arg1: command.append("%1") else: system32 = Path(os.environ.get("SystemRoot", "C:\\Windows")) / "system32" arg1 = "%1 " if with_arg1 else "" # This is an UGLY hack to start the script in a hidden window # We use CMD to call PowerShell to call the BAT file # This flashes faster than Powershell -> BAT! Don't ask me why. command = [ f'"{system32 / "cmd.exe"}"', "/D", "/C", "START", "/MIN", '""', f'"{system32 / "WindowsPowerShell" / "v1.0" / "powershell.exe"}"', "-WindowStyle", "hidden", f"\"start '{script}' {arg1}-WindowStyle hidden\"", ] return command command = self.render_key("command") if with_arg1 and all("%1" not in arg for arg in command): command.append("%1") return WinLex.quote_args(command) def _ftype_identifier(self, extension): identifier = self.render_key("name", slug=True) return f"{identifier}.AssocFile{extension}" def _register_file_extensions_cmd(self): """ This function uses CMD's `assoc` and `ftype` commands. """ extensions = self.metadata["file_extensions"] if not extensions: return command = " ".join(self._process_command()) exts = list(dict.fromkeys([ext.lower() for ext in extensions])) for ext in exts: identifier = self._ftype_identifier(ext) self._cmd_ftype(identifier, command) self._cmd_assoc(ext, associate_to=identifier) def _unregister_file_extensions_cmd(self): """ This function uses CMD's `assoc` and `ftype` commands. """ extensions = self.metadata["file_extensions"] if not extensions: return exts = list(dict.fromkeys([ext.lower() for ext in extensions])) for ext in exts: identifier = self._ftype_identifier(ext) self._cmd_ftype(identifier) # remove # TODO: Do we need to clean up the `assoc` mappings too? @staticmethod def _cmd_assoc(extension, associate_to=None, query=False, remove=False) -> CompletedProcess: "https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/assoc" if sum([associate_to, query, remove]) != 1: raise ValueError("Only one of {associate_to, query, remove} must be set.") if not extension.startswith("."): raise ValueError("extension must startwith '.'") if associate_to: arg = f"{extension}={associate_to}" elif query: arg = extension elif remove: arg = f"{extension}=" return logged_run(["cmd", "/D", "/C", f"assoc {arg}"], check=True) @staticmethod def _cmd_ftype(identifier, command=None, query=False, remove=False) -> CompletedProcess: "https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/ftype" if sum([command, query, remove]) != 1: raise ValueError("Only one of {command, query, remove} must be set.") if command: arg = f"{identifier}={command}" elif query: arg = identifier elif remove: arg = f"{identifier}=" return logged_run(["cmd", "/D", "/C", f"assoc {arg}"], check=True) def _register_file_extensions(self): """WIP""" extensions = self.metadata["file_extensions"] if not extensions: return command = " ".join(self._process_command(with_arg1=True)) icon = self.render_key("icon") exts = list(dict.fromkeys([ext.lower() for ext in extensions])) for ext in exts: identifier = self._ftype_identifier(ext) register_file_extension(ext, identifier, command, icon=icon, mode=self.menu.mode) def _unregister_file_extensions(self): extensions = self.metadata["file_extensions"] if not extensions: return exts = list(dict.fromkeys([ext.lower() for ext in extensions])) for ext in exts: identifier = self._ftype_identifier(ext) unregister_file_extension(ext, identifier, mode=self.menu.mode) def _register_url_protocols(self): "See https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa767914(v=vs.85)" # noqa protocols = self.metadata["url_protocols"] if not protocols: return command = " ".join(self._process_command(with_arg1=True)) icon = self.render_key("icon") for protocol in protocols: identifier = self._ftype_identifier(protocol) register_url_protocol(protocol, command, identifier, icon=icon, mode=self.menu.mode) def _unregister_url_protocols(self): protocols = self.metadata["url_protocols"] if not protocols: return for protocol in protocols: identifier = self._ftype_identifier(protocol) unregister_url_protocol(protocol, identifier, mode=self.menu.mode) def _app_user_model_id(self): aumi = self.render_key("app_user_model_id") if not aumi: return f"Menuinst.{self.render_key('name', slug=True).replace('.', '')}"[:128] return aumi