Compare commits

..

12 commits

10 changed files with 236 additions and 66 deletions

View file

@ -1,6 +1,5 @@
TODO TODO
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
gopher plugin
@ -28,6 +27,8 @@ identity management
configurable keybinds configurable keybinds
handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt) handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
allow encoding overrides (useful for gopher i guess) allow encoding overrides (useful for gopher i guess)
config for web browser, default to webbrowser module
use pubkeys instead of the whole DER hash for TOFU
@ -60,3 +61,4 @@ basic local browsing
search in page search in page
plugin interface for schemes plugin interface for schemes
finger plugin finger plugin
gopher plugin

View file

@ -5,7 +5,8 @@ Bebop is a [Gemini][gemini] browser for the terminal, focusing on practicality
and laziness. It is a personal project to learn how to use ncurses and try new and laziness. It is a personal project to learn how to use ncurses and try new
ways to explore the Geminispace. It borrows some ideas from [Amfora][amfora], ways to explore the Geminispace. It borrows some ideas from [Amfora][amfora],
another great terminal browser, Vim for interactivity and tries to support mouse another great terminal browser, Vim for interactivity and tries to support mouse
usage decently. usage decently. It also supports some “smol net” protocols such as Finger and
Gopher through plugins.
[gemini]: https://gemini.circumlunar.space/ [gemini]: https://gemini.circumlunar.space/
[amfora]: https://github.com/makeworld-the-better-one/amfora [amfora]: https://github.com/makeworld-the-better-one/amfora
@ -108,13 +109,15 @@ works OK in cmd.exe, but it feels completely broken on Windows Terminal.
Since v0.2.0 you can use and develop plugins for Bebop. Installing a plugin Since v0.2.0 you can use and develop plugins for Bebop. Installing a plugin
requires you to install the corresponding Python package, usually from PyPI, requires you to install the corresponding Python package, usually from PyPI,
then enable the plugin in your config file. Check the internal help page or the then enable the plugin in your config file. Check the internal help page or the
`bebop/plugin.py` module for more information. `bebop/plugins.py` module for more information.
Here is a list of plugins I did, available on PyPI: Here is a list of plugins I did, available on PyPI:
- `bebop-browser-finger`: support for Finger protocol ([readme][plugin-finger]) - `bebop-browser-finger`: support for Finger protocol ([readme][plugin-finger])
- `bebop-browser-gopher`: support for Gopher protocol ([readme][plugin-gopher])
[plugin-finger]: plugins/finger/README.md [plugin-finger]: plugins/finger/README.md
[plugin-gopher]: plugins/gopher/README.md

View file

@ -63,7 +63,7 @@ class Browser:
values are dicts as well: the "open" key maps to a callable to use when values are dicts as well: the "open" key maps to a callable to use when
the page is accessed, and the optional "source" key maps to callable the page is accessed, and the optional "source" key maps to callable
returning the page source path. returning the page source path.
- last_download: tuple of MimeType and path, or None. - last_download: tuple of MimeType (may be None) and path, or None.
- identities: identities map. - identities: identities map.
- search_res_lines: list of lines containing results of the last search. - search_res_lines: list of lines containing results of the last search.
""" """
@ -84,7 +84,7 @@ class Browser:
self.history = History(self.config["history_limit"]) self.history = History(self.config["history_limit"])
self.cache = {} self.cache = {}
self.special_pages = self.setup_special_pages() self.special_pages = self.setup_special_pages()
self.last_download: Optional[Tuple[MimeType, Path]] = None self.last_download: Optional[Tuple[Optional[MimeType], Path]] = None
self.identities = {} self.identities = {}
self.search_res_lines = [] self.search_res_lines = []
self.plugins = [] self.plugins = []
@ -358,6 +358,14 @@ class Browser:
return return
command = words[0] command = words[0]
# Check for plugin registered commands first.
for plugin in self.plugins:
if command in map(lambda c: c.name, plugin.commands):
plugin.use_command(self, command, command_text)
return
# Then built-in commands without args.
if num_words == 1: if num_words == 1:
if command == "help": if command == "help":
self.open_help() self.open_help()
@ -368,6 +376,8 @@ class Browser:
elif command in ("i", "info"): elif command in ("i", "info"):
self.show_page_info() self.show_page_info()
return return
# And commands with one or more args.
if command in ("o", "open"): if command in ("o", "open"):
self.open_url(words[1]) self.open_url(words[1])
elif command == "forget-certificate": elif command == "forget-certificate":
@ -715,7 +725,7 @@ class Browser:
def open_help(self): def open_help(self):
"""Show the help page.""" """Show the help page."""
self.open_internal_page("help", get_help(self.config)) self.open_internal_page("help", get_help(self.config, self.plugins))
def prompt(self, text: str, keys: str ="yn"): def prompt(self, text: str, keys: str ="yn"):
"""Display the text and allow it to type one of the given keys.""" """Display the text and allow it to type one of the given keys."""
@ -732,7 +742,8 @@ class Browser:
if not self.last_download: if not self.last_download:
return return
mime_type, path = self.last_download mime_type, path = self.last_download
command = self.config["external_commands"].get(mime_type.main_type) main_type = mime_type.main_type if mime_type else ""
command = self.config["external_commands"].get(main_type)
if not command: if not command:
command = self.config["external_command_default"] command = self.config["external_command_default"]
command = command + [str(path)] command = command + [str(path)]

View file

@ -1,12 +1,12 @@
"""Gemini-related features of the browser.""" """Gemini-related features of the browser."""
import logging import logging
from pathlib import Path
from typing import Optional from typing import Optional
from bebop.browser.browser import Browser from bebop.browser.browser import Browser
from bebop.command_line import CommandLine from bebop.command_line import CommandLine
from bebop.fs import get_downloads_path, get_identities_list_path from bebop.downloads import get_download_path
from bebop.fs import get_identities_list_path
from bebop.identity import ( from bebop.identity import (
ClientCertificateException, create_certificate, get_cert_and_key, ClientCertificateException, create_certificate, get_cert_and_key,
get_identities_for_url, load_identities, save_identities get_identities_for_url, load_identities, save_identities
@ -230,7 +230,7 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
page.encoding = encoding page.encoding = encoding
else: else:
download_dir = browser.config["download_path"] download_dir = browser.config["download_path"]
filepath = _get_download_path(url, download_dir=download_dir) filepath = get_download_path(url, download_dir=download_dir)
# If a page has been produced, load it. Else if a file has been retrieved, # If a page has been produced, load it. Else if a file has been retrieved,
# download it. # download it.
@ -254,20 +254,6 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
return None return None
def _get_download_path(url: str, download_dir: Optional[str] =None) -> Path:
"""Try to find the best download file path possible from this URL."""
download_path = Path(download_dir) if download_dir else get_downloads_path()
if not download_path.exists():
download_path.mkdir(parents=True)
url_parts = url.rsplit("/", maxsplit=1)
if url_parts:
filename = url_parts[-1]
else:
filename = url.split("://")[1] if "://" in url else url
filename = filename.replace("/", "_")
return download_path / filename
def _handle_input_request( def _handle_input_request(
browser: Browser, browser: Browser,
from_url: str, from_url: str,

20
bebop/downloads.py Normal file
View file

@ -0,0 +1,20 @@
"""Downloads management."""
from pathlib import Path
from typing import Optional
from bebop.fs import get_downloads_path
def get_download_path(url: str, download_dir: Optional[str] =None) -> Path:
"""Try to find the best download file path possible from this URL."""
download_path = Path(download_dir) if download_dir else get_downloads_path()
if not download_path.exists():
download_path.mkdir(parents=True)
url_parts = url.rsplit("/", maxsplit=1)
if url_parts:
filename = url_parts[-1]
else:
filename = url.split("://")[1] if "://" in url else url
filename = filename.replace("/", "_")
return download_path / filename

View file

@ -54,6 +54,7 @@ Commands are mostly for actions requiring user input. You can type a command wit
* i(nfo): show page informations * i(nfo): show page informations
* forget-certificate <hostname>: remove saved fingerprint for this hostname * forget-certificate <hostname>: remove saved fingerprint for this hostname
* set-render-mode: set render mode preference for the current URL * set-render-mode: set render mode preference for the current URL
{plugin_commands}
## Configuration ## Configuration
@ -80,24 +81,33 @@ 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_<plugin-name>` package name. 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_<plugin-name>` package name.
Your current configuration is: Your current configuration is:
{current_config}
""" """
def get_help(config): def get_help(config, plugins):
plugin_commands = "\n".join(
f"* {command.name}: {command.description}"
for plugin in plugins
for command in plugin.commands
)
config_list = "\n".join( config_list = "\n".join(
( (
f'* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})' f"* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})"
if value != DEFAULT_CONFIG[key] if value != DEFAULT_CONFIG[key]
else f'* {key} = {value}' else f"* {key} = {value}"
) )
for key, value in config.items() for key, value in config.items()
) )
return HELP_PAGE + config_list return HELP_PAGE.format(
plugin_commands=plugin_commands,
current_config=config_list
)

View file

@ -18,15 +18,58 @@ There is at least one plugin in this repository in the `plugins` directory.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional from typing import Optional
from bebop.browser.browser import Browser from bebop.browser.browser import Browser
class SchemePlugin(ABC): @dataclass
"""Plugin for URL scheme management.""" class PluginCommand:
"""A descriptor for a plugin command.
Attributes:
- name: the command name.
- description: a very short description of the command; should start lower
case and does not need a period at the end.
"""
name: str
description: str
class Plugin(ABC):
"""Base class for plugins.
Attributes:
- commands: list of PluginCommand provided by the plugin.
"""
def __init__(self) -> None:
super().__init__()
self.commands = []
def use_command(self, browser: Browser, name: str, text: str):
"""Use a command presented by this plugin.
Plugins that do not use custom commands can leave this method
unimplemented.
Attributes:
- name: the command used as it is in the commands list.
- text: the whole command text, including the command name.
"""
pass
class SchemePlugin(Plugin):
"""Plugin for URL scheme management.
If you want to create a plugin that can handle new schemes, create a plugin
inheriting this class.
"""
def __init__(self, scheme: str) -> None: def __init__(self, scheme: str) -> None:
super().__init__()
self.scheme = scheme self.scheme = scheme
@abstractmethod @abstractmethod

View file

@ -8,3 +8,11 @@ This is a Gopher plugin for [Bebop][bebop], refer to its docs for details.
Requires: Requires:
* Bebop >= 0.2.0 * Bebop >= 0.2.0
It currently displays only the maps and the file item types.
Avoid using the path navigation features of Bebop because they do not make much
sense in Gopher; Gopher URLs do not really represent a path as they can be
prefixed with an item type, so going up one level from a file item will usually
put you on a map item with the file item indicator still in the URL. Going to
the root URL and history navigation should still be fine though.

View file

@ -6,11 +6,13 @@ from typing import Optional
from bebop.browser.browser import Browser from bebop.browser.browser import Browser
from bebop.command_line import CommandLine from bebop.command_line import CommandLine
from bebop.downloads import get_download_path
from bebop.links import Links from bebop.links import Links
from bebop.metalines import LineType from bebop.metalines import LineType
from bebop.navigation import parse_url, parse_host_and_port from bebop.mime import MimeType
from bebop.navigation import parse_url, parse_host_and_port, unparse_url
from bebop.page import Page from bebop.page import Page
from bebop.plugins import SchemePlugin from bebop.plugins import PluginCommand, SchemePlugin
class ItemType(Enum): class ItemType(Enum):
@ -36,15 +38,23 @@ class ItemType(Enum):
_missing_ = lambda s: ItemType.FILE _missing_ = lambda s: ItemType.FILE
# Types that can be parsed as a page (see `parse_source`).
PARSABLE_TYPES = (ItemType.FILE, ItemType.DIR)
# Types that are not rendered by this plugin; should be handled by a separate
# program, but for now we simply do nothing with them.
UNHANDLED_TYPES = ( UNHANDLED_TYPES = (
ItemType.CCSO, ItemType.ERROR, ItemType.TELNET, ItemType.REDUNDANT, ItemType.CCSO, ItemType.ERROR, ItemType.TELNET, ItemType.REDUNDANT,
ItemType.TN3270 ItemType.TN3270
) )
# Map item types lowercase names to the actual type, to easily set a type from
# the command-line.
USER_FRIENDLY_TYPES = {t.name.lower(): t for t in ItemType}
# Icons to display for some item types in a Gopher map.
ICONS = { ICONS = {
ItemType.FILE: "📄", ItemType.FILE: "📄",
ItemType.DIR: "📂", ItemType.DIR: "📂",
ItemType.ERROR: "", ItemType.ERROR: "",
ItemType.SEARCH: "🤔", ItemType.SEARCH: "",
ItemType.HTML: "🌐", ItemType.HTML: "🌐",
} }
@ -64,8 +74,20 @@ class GopherPlugin(SchemePlugin):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__("gopher") super().__init__("gopher")
self.commands = [
PluginCommand(
"set-item-type",
"display current page as another item type (Gopher only)"
)
]
def open_url(self, browser: Browser, url: str) -> Optional[str]: def open_url(self, browser: Browser, url: str) -> Optional[str]:
"""Request an selector from a Gopher host.
As Bebop works only with URLs and not really the Gopher host/selector
format, we use RFC 4266 (The gopher URI Scheme) for consistency with
other schemes and to get that sweet item type hint in the URL path.
"""
parts = parse_url(url) parts = parse_url(url)
host = parts["netloc"] host = parts["netloc"]
host_and_port = parse_host_and_port(host, 70) host_and_port = parse_host_and_port(host, 70)
@ -73,7 +95,8 @@ class GopherPlugin(SchemePlugin):
browser.set_status_error("Could not parse gopher URL.") browser.set_status_error("Could not parse gopher URL.")
return None return None
host, port = host_and_port host, port = host_and_port
path = parts["path"] # Decode path; spaces in Gopher URLs are encoded for display in Bebop.
path = parts["path"].replace("%20", " ")
# If the URL has an item type, use it to properly parse the response. # If the URL has an item type, use it to properly parse the response.
type_path_match = TYPE_PATH_RE.match(path) type_path_match = TYPE_PATH_RE.match(path)
@ -103,24 +126,74 @@ class GopherPlugin(SchemePlugin):
else: else:
item_type = ItemType.DIR item_type = ItemType.DIR
# If we have text search in our path, encode it for UI & logging. # If we have spaces in our path, encode it for UI & logging.
encoded_path = path.replace("\t", "%09") encoded_path = path.replace(" ", "%20").replace("\t", "%09")
browser.set_status(f"Loading {host} {port} '{encoded_path}'") browser.set_status(f"Loading {host} {port} '{encoded_path}'")
timeout = browser.config["connect_timeout"] timeout = browser.config["connect_timeout"]
try: try:
response = self.request(host, port, path, timeout) response = request(host, port, path, timeout)
page = parse_response(response, item_type)
except GopherPluginException as exc: except GopherPluginException as exc:
browser.set_status_error("Error: " + exc.message) browser.set_status_error("Error: " + exc.message)
return None return None
browser.load_page(page)
url = f"gopher://{host}:{port}/{item_type.value}{encoded_path}" url = f"gopher://{host}:{port}/{item_type.value}{encoded_path}"
if item_type in PARSABLE_TYPES:
page = parse_response(response, item_type)
browser.load_page(page)
browser.current_url = url browser.current_url = url
else:
download_dir = browser.config["download_path"]
filepath = get_download_path(url, download_dir=download_dir)
try:
with open(filepath, "wb") as download_file:
download_file.write(response)
except OSError as exc:
browser.set_status_error(f"Failed to save {url} ({exc})")
return None
else:
browser.set_status(f"Downloaded {url}.")
browser.last_download = None, filepath
return url return url
def request(self, host: str, port: int, path: str, timeout: int): def use_command(self, browser: Browser, name: str, text: str):
if name == "set-item-type":
given_type = text[len(name):].strip()
valid_types = [
t for t in USER_FRIENDLY_TYPES
if USER_FRIENDLY_TYPES[t] not in UNHANDLED_TYPES
]
if given_type not in valid_types:
error = "Valid types: " + ", ".join(valid_types)
browser.set_status_error(error)
return
item_type = USER_FRIENDLY_TYPES[given_type]
self.set_item_type(browser, item_type)
def set_item_type(self, browser: Browser, item_type: ItemType):
"""Re-parse the current page using this item type."""
if browser.current_scheme != self.scheme or not browser.current_page:
browser.set_status_error("Can only set item types on Gopher URLs.")
return
logging.debug(f"Force parsing current page as {item_type}")
current_source = browser.current_page.source
new_page = get_page_from_source(current_source, item_type)
browser.load_page(new_page)
# If possible, set the correct item type in the URL path as well.
url = browser.current_url
parts = parse_url(browser.current_url)
type_path_match = TYPE_PATH_RE.match(parts["path"])
if type_path_match:
path = type_path_match.group(2)
parts["path"] = f"/{item_type.value}{path}"
browser.current_url = unparse_url(parts)
def request(host: str, port: int, path: str, timeout: int) -> bytes:
"""Send a Gopher request and return the received bytes."""
try: try:
sock = socket.create_connection((host, port), timeout=timeout) sock = socket.create_connection((host, port), timeout=timeout)
except OSError as exc: except OSError as exc:
@ -145,12 +218,23 @@ class GopherPlugin(SchemePlugin):
def parse_response(response: bytes, item_type: ItemType, encoding: str ="utf8"): def parse_response(response: bytes, item_type: ItemType, encoding: str ="utf8"):
"""Parse a Gopher response."""
decoded = response.decode(encoding=encoding, errors="replace") decoded = response.decode(encoding=encoding, errors="replace")
metalines, links = parse_source(decoded, item_type) return get_page_from_source(decoded, item_type)
return Page(decoded, metalines, links)
def get_page_from_source(source: str, item_type: ItemType):
"""Get a Page object from a decoded source text."""
metalines, links = parse_source(source, item_type)
return Page(source, metalines, links)
def parse_source(source: str, item_type: ItemType): def parse_source(source: str, item_type: ItemType):
"""Generate metalines and a Links instance for this source text.
The item_type must be a type that can be parsed: FILE or DIR. Any other
item type will silently result in no metalines.
"""
metalines = [] metalines = []
links = Links() links = Links()
@ -163,7 +247,10 @@ def parse_source(source: str, item_type: ItemType):
# parse any kind of text data. # parse any kind of text data.
elif item_type == ItemType.DIR: elif item_type == ItemType.DIR:
current_link_id = 1 current_link_id = 1
for line in source.split("\r\n"): # Split lines on \n and discard \r separately because some maps do not
# end lines with \r\n all the time.
for line in source.split("\n"):
line = line.rstrip("\r")
ltype, tline = line[:1], line[1:] ltype, tline = line[:1], line[1:]
if ltype == "." and not tline: if ltype == "." and not tline:
break break

View file

@ -1,6 +1,6 @@
[metadata] [metadata]
name = bebop-browser name = bebop-browser
version = 0.2.0 version = 0.3.0
description = Terminal browser for Gemini description = Terminal browser for Gemini
long_description = file: README.md long_description = file: README.md
license = GPLv3 license = GPLv3