diff --git a/bebop/browser/browser.py b/bebop/browser/browser.py index 92d1383..b8f54c3 100644 --- a/bebop/browser/browser.py +++ b/bebop/browser/browser.py @@ -7,6 +7,7 @@ import logging import os import subprocess import tempfile +from importlib import import_module from math import inf from pathlib import Path from typing import Optional, Tuple @@ -86,6 +87,7 @@ class Browser: self.last_download: Optional[Tuple[MimeType, Path]] = None self.identities = {} self.search_res_lines = [] + self.plugins = [] self._current_url = "" @property @@ -176,6 +178,12 @@ class Browser: if not self.history.load(): logging.warning("Could not load history file.") + # Load plugins. + self.load_plugins() + + # If there has been any issue to load user files, show them instead of + # automatically moving forward. Else either open the URL requested or + # show the home page. if failed_to_load: error_msg = ( f"Failed to open some local data: {', '.join(failed_to_load)}. " @@ -455,7 +463,15 @@ class Browser: self.set_status_error("Unknown page.") else: - self.set_status_error(f"Protocol '{scheme}' not supported.") + from bebop.plugins import SchemePlugin + plugins = (p for p in self.plugins if isinstance(p, SchemePlugin)) + plugin = next(filter(lambda p: p.scheme == scheme, plugins), None) + if plugin: + result_url = plugin.open_url(self, url) + if history and result_url: + self.history.push(result_url) + else: + self.set_status_error(f"Protocol '{scheme}' not supported.") def load_page(self, page: Page): """Set this page as the current page and refresh appropriate windows.""" @@ -828,3 +844,22 @@ class Browser: self.set_status(f"Result {index}/{max_index}") self.page_pad.current_line = next_line self.refresh_windows() + + def load_plugins(self): + """Load installed and configured plugins.""" + for plugin_name in self.config["enabled_plugins"]: + module_name = f"bebop_{plugin_name}" + + try: + module = import_module(module_name) + except ImportError as exc: + logging.error(f"Could not load module {module_name}: {exc}") + continue + + try: + self.plugins.append(module.plugin) # type: ignore + except AttributeError: + logging.error(f"Module {module_name} does not export a plugin.") + continue + + logging.info(f"Loaded plugin {plugin_name}.") diff --git a/bebop/config.py b/bebop/config.py index 9b58baf..5ee158c 100644 --- a/bebop/config.py +++ b/bebop/config.py @@ -30,6 +30,7 @@ DEFAULT_CONFIG = { ], "scroll_step": 3, "persistent_history": False, + "enabled_plugins": [], } RENDER_MODES = ("fancy", "dumb") diff --git a/bebop/help.py b/bebop/help.py index f923d53..904120d 100644 --- a/bebop/help.py +++ b/bebop/help.py @@ -64,6 +64,7 @@ Here are the available options: * command_editor (see note 1): command to use for editing cli input. * connect_timeout (int): seconds before connection times out. * download_path (string): download path. +* enabled_plugins: (see note 4): plugin names to load. * external_command_default (see note 1): default command to open files. * external_commands (see note 2): commands to open various files. * generate_client_cert_command (see note 3): command to generate a client cert. @@ -77,11 +78,13 @@ Here are the available options: Notes: -1: for the "command" parameters such as source_editor and command_editor, a string list is used to separate the different program arguments, e.g. if you wish to use `vim -c 'startinsert'`, you should write the list `["vim", "-c", "startinsert"]`. In both case, a temporary or regular file name will be appended to this command when run. +1: For the "command" parameters such as source_editor and command_editor, a string list is used to separate the different program arguments, e.g. if you wish to use `vim -c 'startinsert'`, you should write the list `["vim", "-c", "startinsert"]`. In both case, a temporary or regular file name will be appended to this command when run. -2: the external_commands dict maps MIME types to commands just as above. For example, if you want to open video files with VLC and audio files in Clementine, you can use the following dict: `{"audio": ["clementine"], "video": ["vlc"]}`. For now only "main" MIME types are supported, i.e. you cannot specify precise types like "audio/flac", just "audio". +2: The external_commands dict maps MIME types to commands just as above. For example, if you want to open video files with VLC and audio files in Clementine, you can use the following dict: `{"audio": ["clementine"], "video": ["vlc"]}`. For now only "main" MIME types are supported, i.e. you cannot specify precise types like "audio/flac", just "audio". -3: the generate_client_cert_command uses the same format as other commands (specified in note 1 above), with the exception that if the strings "{cert_path}", "{key_path}" or "{common_name}" are present in any string for the list, they will be replaced respectively by the certificate output path, the key output path and the CN to use. +3: The generate_client_cert_command uses the same format as other commands (specified in note 1 above), with the exception that if the strings "{cert_path}", "{key_path}" or "{common_name}" are present in any string for the list, they will be replaced respectively by the certificate output path, the key output path and the CN to use. + +4: The enabled_plugins list contain plugin names to load. Plugins are available if they are installed Python packages that can be imported using the `bebop_` package name. Your current configuration is: diff --git a/bebop/plugins.py b/bebop/plugins.py new file mode 100644 index 0000000..9d4d1c7 --- /dev/null +++ b/bebop/plugins.py @@ -0,0 +1,32 @@ +"""Plugin management. + +Plugins are here to allow extending Bebop with additional features, potentially +requiring external libraries, without requiring users who just want a Gemini +browser to install anything. + +Support for plugins is very simple right now: a plugin can only register an URL +scheme to handle. +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from bebop.browser.browser import Browser + + +class SchemePlugin(ABC): + """Plugin for URL scheme management.""" + + def __init__(self, scheme: str) -> None: + self.scheme = scheme + + @abstractmethod + def open_url(self, browser: Browser, url: str) -> Optional[str]: + """Handle an URL for this scheme. + + Returns: + The properly handled URL at the end of this query, which may be + different from the url parameter if redirections happened, or None if an + error happened. + """ + raise NotImplementedError