Compare commits

...

12 commits

10 changed files with 236 additions and 66 deletions

View file

@ -1,6 +1,5 @@
TODO
--------------------------------------------------------------------------------
gopher plugin
@ -28,6 +27,8 @@ identity management
configurable keybinds
handle big files (e.g. gemini://tilde.team/~tomasino/irc/log.txt)
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
plugin interface for schemes
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
ways to explore the Geminispace. It borrows some ideas from [Amfora][amfora],
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/
[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
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
`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:
- `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-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
the page is accessed, and the optional "source" key maps to callable
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.
- 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.cache = {}
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.search_res_lines = []
self.plugins = []
@ -358,6 +358,14 @@ class Browser:
return
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 command == "help":
self.open_help()
@ -368,6 +376,8 @@ class Browser:
elif command in ("i", "info"):
self.show_page_info()
return
# And commands with one or more args.
if command in ("o", "open"):
self.open_url(words[1])
elif command == "forget-certificate":
@ -715,7 +725,7 @@ class Browser:
def open_help(self):
"""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"):
"""Display the text and allow it to type one of the given keys."""
@ -732,7 +742,8 @@ class Browser:
if not self.last_download:
return
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:
command = self.config["external_command_default"]
command = command + [str(path)]

View file

@ -1,12 +1,12 @@
"""Gemini-related features of the browser."""
import logging
from pathlib import Path
from typing import Optional
from bebop.browser.browser import Browser
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 (
ClientCertificateException, create_certificate, get_cert_and_key,
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
else:
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,
# download it.
@ -254,20 +254,6 @@ def _handle_successful_response(browser: Browser, response: Response, url: str):
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(
browser: Browser,
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
* forget-certificate <hostname>: remove saved fingerprint for this hostname
* set-render-mode: set render mode preference for the current URL
{plugin_commands}
## 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.
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.
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(
(
f'* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})'
f"* {key} = {value} (default {repr(DEFAULT_CONFIG[key])})"
if value != DEFAULT_CONFIG[key]
else f'* {key} = {value}'
else f"* {key} = {value}"
)
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 dataclasses import dataclass
from typing import Optional
from bebop.browser.browser import Browser
class SchemePlugin(ABC):
"""Plugin for URL scheme management."""
@dataclass
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:
super().__init__()
self.scheme = scheme
@abstractmethod

View file

@ -8,3 +8,11 @@ This is a Gopher plugin for [Bebop][bebop], refer to its docs for details.
Requires:
* 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.command_line import CommandLine
from bebop.downloads import get_download_path
from bebop.links import Links
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.plugins import SchemePlugin
from bebop.plugins import PluginCommand, SchemePlugin
class ItemType(Enum):
@ -36,15 +38,23 @@ class ItemType(Enum):
_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 = (
ItemType.CCSO, ItemType.ERROR, ItemType.TELNET, ItemType.REDUNDANT,
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 = {
ItemType.FILE: "📄",
ItemType.DIR: "📂",
ItemType.ERROR: "",
ItemType.SEARCH: "🤔",
ItemType.SEARCH: "",
ItemType.HTML: "🌐",
}
@ -64,8 +74,20 @@ class GopherPlugin(SchemePlugin):
def __init__(self) -> None:
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]:
"""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)
host = parts["netloc"]
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.")
return None
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.
type_path_match = TYPE_PATH_RE.match(path)
@ -103,24 +126,74 @@ class GopherPlugin(SchemePlugin):
else:
item_type = ItemType.DIR
# If we have text search in our path, encode it for UI & logging.
encoded_path = path.replace("\t", "%09")
# If we have spaces in our path, encode it for UI & logging.
encoded_path = path.replace(" ", "%20").replace("\t", "%09")
browser.set_status(f"Loading {host} {port} '{encoded_path}'")
timeout = browser.config["connect_timeout"]
try:
response = self.request(host, port, path, timeout)
page = parse_response(response, item_type)
response = request(host, port, path, timeout)
except GopherPluginException as exc:
browser.set_status_error("Error: " + exc.message)
return None
browser.load_page(page)
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
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
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:
sock = socket.create_connection((host, port), timeout=timeout)
except OSError as exc:
@ -145,12 +218,23 @@ class GopherPlugin(SchemePlugin):
def parse_response(response: bytes, item_type: ItemType, encoding: str ="utf8"):
"""Parse a Gopher response."""
decoded = response.decode(encoding=encoding, errors="replace")
metalines, links = parse_source(decoded, item_type)
return Page(decoded, metalines, links)
return get_page_from_source(decoded, item_type)
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):
"""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 = []
links = Links()
@ -163,7 +247,10 @@ def parse_source(source: str, item_type: ItemType):
# parse any kind of text data.
elif item_type == ItemType.DIR:
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:]
if ltype == "." and not tline:
break

View file

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