#!/usr/bin/env python # License: GPLv3 Copyright: 2020, Kovid Goyal import os import posixpath import shlex from collections.abc import Iterable, Iterator from contextlib import suppress from typing import Any, NamedTuple, cast from urllib.parse import ParseResult, unquote, urlparse from .conf.utils import KeyAction, to_cmdline_implementation from .constants import config_dir from .fast_data_types import get_options from .guess_mime_type import guess_type from .options.utils import ActionAlias, MapType, resolve_aliases_and_parse_actions from .types import run_once from .typing_compat import MatchType from .utils import expandvars, get_editor, log_error, resolved_shell class MatchCriteria(NamedTuple): type: MatchType value: str class OpenAction(NamedTuple): match_criteria: tuple[MatchCriteria, ...] actions: tuple[KeyAction, ...] def parse(lines: Iterable[str]) -> Iterator[OpenAction]: match_criteria: list[MatchCriteria] = [] raw_actions: list[str] = [] alias_map: dict[str, list[ActionAlias]] = {} entries = [] for line in lines: line = line.strip() if line.startswith('#'): continue if not line: if match_criteria and raw_actions: entries.append((tuple(match_criteria), tuple(raw_actions))) match_criteria = [] raw_actions = [] continue parts = line.split(maxsplit=1) if len(parts) != 2: continue key, rest = parts key = key.lower() if key == 'action': raw_actions.append(rest) elif key in ('mime', 'ext', 'protocol', 'file', 'path', 'url', 'fragment_matches'): if key != 'url': rest = rest.lower() match_criteria.append(MatchCriteria(cast(MatchType, key), rest)) elif key == 'action_alias': try: alias_name, alias_val = rest.split(maxsplit=1) except Exception: continue alias_map[alias_name] = [ActionAlias(alias_name, alias_val)] else: log_error(f'Ignoring malformed open actions line: {line}') if match_criteria and raw_actions: entries.append((tuple(match_criteria), tuple(raw_actions))) with to_cmdline_implementation.filter_env_vars( 'URL', 'FILE_PATH', 'FILE', 'FRAGMENT', 'URL_PATH', 'NETLOC', EDITOR=shlex.join(get_editor()), SHELL=resolved_shell(get_options())[0] ): for (mc, action_defns) in entries: actions: list[KeyAction] = [] for defn in action_defns: actions.extend(resolve_aliases_and_parse_actions(defn, alias_map, MapType.OPEN_ACTION)) yield OpenAction(mc, tuple(actions)) def url_matches_criterion(purl: 'ParseResult', url: str, unquoted_path: str, mc: MatchCriteria) -> bool: if mc.type == 'url': import re try: pat = re.compile(mc.value) except re.error: return False return pat.search(unquote(url)) is not None if mc.type == 'mime': import fnmatch mt = guess_type(unquoted_path, allow_filesystem_access=purl.scheme in ('', 'file')) if not mt: return False mt = mt.lower() for mpat in mc.value.split(','): mpat = mpat.strip() with suppress(Exception): if fnmatch.fnmatchcase(mt, mpat): return True return False if mc.type == 'ext': if not purl.path: return False path = unquoted_path.lower() for ext in mc.value.split(','): ext = ext.strip() if path.endswith(f'.{ext}'): return True return False if mc.type == 'protocol': protocol = (purl.scheme or 'file').lower() for key in mc.value.split(','): if key.strip() == protocol: return True return False if mc.type == 'fragment_matches': import re try: pat = re.compile(mc.value) except re.error: return False return pat.search(unquote(purl.fragment)) is not None if mc.type == 'path': import fnmatch try: return fnmatch.fnmatchcase(unquoted_path.lower(), mc.value) except Exception: return False if mc.type == 'file': import fnmatch try: fname = posixpath.basename(unquoted_path) except Exception: return False try: return fnmatch.fnmatchcase(fname.lower(), mc.value) except Exception: return False def url_matches_criteria(purl: 'ParseResult', url: str, unquoted_path: str, criteria: Iterable[MatchCriteria]) -> bool: for x in criteria: try: if not url_matches_criterion(purl, url, unquoted_path, x): return False except Exception: return False return True def actions_for_url_from_list(url: str, actions: Iterable[OpenAction]) -> Iterator[KeyAction]: try: purl = urlparse(url) except Exception: return path = unquote(purl.path) up = purl.path netloc = unquote(purl.netloc) if purl.netloc else '' if purl.query: up += f'?{purl.query}' frag = unquote(purl.fragment) if purl.fragment else '' if frag: up += f'#{purl.fragment}' env = { 'URL': url, 'FILE_PATH': path, 'URL_PATH': up, 'FILE': posixpath.basename(path), 'FRAGMENT': frag, 'NETLOC': netloc, } def expand(x: Any) -> Any: as_bytes = isinstance(x, bytes) if as_bytes: x = x.decode('utf-8') if isinstance(x, str): ans = expandvars(x, env, fallback_to_os_env=False) if as_bytes: return ans.encode('utf-8') return ans return x for action in actions: if url_matches_criteria(purl, url, path, action.match_criteria): for ac in action.actions: yield ac._replace(args=tuple(map(expand, ac.args))) return actions_cache: dict[str, tuple[os.stat_result, tuple[OpenAction, ...]]] = {} def load_actions_from_path(path: str) -> tuple[OpenAction, ...]: try: st = os.stat(path) except OSError: return () x = actions_cache.get(path) if x is None or x[0].st_mtime != st.st_mtime: with open(path) as f: actions_cache[path] = st, tuple(parse(f)) else: return x[1] return actions_cache[path][1] def load_open_actions() -> tuple[OpenAction, ...]: return load_actions_from_path(os.path.join(config_dir, 'open-actions.conf')) def load_launch_actions() -> tuple[OpenAction, ...]: return load_actions_from_path(os.path.join(config_dir, 'launch-actions.conf')) def clear_caches() -> None: actions_cache.clear() @run_once def default_open_actions() -> tuple[OpenAction, ...]: return tuple(parse('''\ # Open kitty HTML docs links protocol kitty+doc action show_kitty_doc $URL_PATH '''.splitlines())) @run_once def default_launch_actions() -> tuple[OpenAction, ...]: return tuple(parse('''\ # Open script files. Change confirm-always to confirm-never or confirm-if-needed to # disable confirmation for all or executable files respectively. protocol file ext sh,command,tool action launch --hold --type=os-window kitten __shebang__ confirm-always $FILE_PATH $SHELL # Open shell specific script files protocol file ext fish,bash,zsh action launch --hold --type=os-window kitten __shebang__ confirm-always $FILE_PATH __ext__ # Open directories protocol file mime inode/directory action launch --type=os-window --cwd -- $FILE_PATH # Open executable file. Remove kitten __confirm_and_run_exe__ to execute # without confirmation. protocol file mime inode/executable,application/vnd.microsoft.portable-executable action launch --hold --type=os-window -- kitten __confirm_and_run_exe__ $FILE_PATH # Open text files without fragments in the editor protocol file mime text/* action launch --type=os-window -- $EDITOR -- $FILE_PATH # Open image files with icat protocol file mime image/* action launch --type=os-window kitten icat --hold -- $FILE_PATH # Open ssh URLs with ssh command protocol ssh action launch --type=os-window ssh -- $URL '''.splitlines())) def actions_for_url(url: str, actions_spec: str | None = None) -> Iterator[KeyAction]: if actions_spec is None: actions = load_open_actions() else: actions = tuple(parse(actions_spec.splitlines())) found = False for action in actions_for_url_from_list(url, actions): found = True yield action if not found: yield from actions_for_url_from_list(url, default_open_actions()) def actions_for_launch(url: str) -> Iterator[KeyAction]: # Custom launch actions using kitty URL scheme needs to be prefixed with `kitty:///launch/` if url.startswith('kitty://') and not url.startswith('kitty:///launch/'): return found = False for action in actions_for_url_from_list(url, load_launch_actions()): found = True yield action if not found: yield from actions_for_url_from_list(url, default_launch_actions())