diff options
Diffstat (limited to 'browser')
-rwxr-xr-x | browser/browser.py | 133 | ||||
-rw-r--r-- | browser/fsm.py | 117 | ||||
-rw-r--r-- | browser/gemini.py | 148 |
3 files changed, 398 insertions, 0 deletions
diff --git a/browser/browser.py b/browser/browser.py new file mode 100755 index 0000000..f5475ab --- /dev/null +++ b/browser/browser.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python + +import sys + +from PySide6 import QtCore, QtWidgets, QtGui, QtWebEngineWidgets, QtWebEngineCore + +import gemini + + +class GeminiPage(QtWebEngineCore.QWebEnginePage): + def acceptNavigationRequest(self, url, navtype, mainframe): + """ + Block non-gemini page navigation and + send the url to the os instead + """ + if url.scheme() == 'gemini': + return True + else: + QtGui.QDesktopServices().openUrl(url) + return False + + +class GeminiSchemeHandler(QtWebEngineCore.QWebEngineUrlSchemeHandler): + def requestStarted(self, request): + request_url = gemini.hack_url(request.requestUrl().toString()) + print(request_url) + gem = gemini.get(request_url) + print(gem['status'], gem['meta']) + buf = QtCore.QBuffer(parent=request) + buf.open(QtCore.QIODevice.WriteOnly) + buf.write(gemini.gem2html(gem).encode('utf8')) + buf.seek(0) + buf.close() + request.reply(b'text/html', buf) + + +class GUrlBar(QtWidgets.QLineEdit): + + def __init__(self): + QtWidgets.QLineEdit.__init__(self) + + def setUrl(self, url): + url = gemini.hack_url(url.toDisplayString()) + return self.setText(url) + + +class GBrowser(QtWidgets.QMainWindow): + + def __init__(self, initial_url=None, profile=None): + if initial_url is None: + initial_url = 'gemini://gemini.circumlunar.space/' + if profile is None: + profile = QtWebEngineCore.QWebEngineProfile.defaultProfile() + QtWidgets.QMainWindow.__init__(self) + + # Main Viewport + self._browser = QtWebEngineWidgets.QWebEngineView() + page = GeminiPage(profile, self._browser) + self._browser.setPage(page) + self.setCentralWidget(self._browser) + + # Navigation Toolbar + back = QtWidgets.QPushButton('←', self) + forward = QtWidgets.QPushButton('→', self) + self._address = GUrlBar() + toolbar = QtWidgets.QToolBar() + toolbar.setObjectName('navigationToolbar') + toolbar.addWidget(back) + toolbar.addWidget(forward) + toolbar.addWidget(self._address) + self.addToolBar(toolbar) + + # Status Bar + request_status = QtWidgets.QLabel() + self.statusBar().addWidget(request_status) + + # Connect signals + back.clicked.connect(self._browser.back) + forward.clicked.connect(self._browser.forward) + self._browser.urlChanged.connect(self._address.setUrl) + self._address.returnPressed.connect(self.loadAddress) + self._browser.page().linkHovered.connect(self.set_status_url) + + self._browser.load(QtCore.QUrl(initial_url)) + + settings = QtCore.QSettings("xcolour.net", "GeminiBrowser") + self.restoreGeometry(settings.value("geometry")) + self.restoreState(settings.value("windowState")) + self.setWindowTitle('Gemini Browser') + self.show() + + # Shortcuts + down_shortcut = QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_J), self) + down_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, QtCore.Qt.Key_Down, QtCore.Qt.NoModifier) + down_shortcut.activated.connect(lambda: QtWidgets.QApplication.sendEvent(self._browser.focusProxy(), down_event)) + up_shortcut = QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_K), self) + up_event = QtGui.QKeyEvent(QtCore.QEvent.KeyPress, QtCore.Qt.Key_Up, QtCore.Qt.NoModifier) + up_shortcut.activated.connect(lambda: QtWidgets.QApplication.sendEvent(self._browser.focusProxy(), up_event)) + QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_H), self, self._browser.back) + QtGui.QShortcut(QtGui.QKeySequence(QtCore.Qt.Key_L), self, self._browser.forward) + QtGui.QShortcut(QtGui.QKeySequence("Shift+R"), self, self._browser.reload) + QtGui.QShortcut(QtGui.QKeySequence("Esc"), self._address, lambda: self.setFocus()) + QtGui.QShortcut(QtGui.QKeySequence("Shift+L"), self, lambda: self._address.setFocus()) + + def set_status_url(self, url): + if url: + self.statusBar().showMessage(url) + else: + self.statusBar().clearMessage() + + def closeEvent(self, event): + settings = QtCore.QSettings("xcolour.net", "GeminiBrowser") + settings.setValue("geometry", self.saveGeometry()) + settings.setValue("windowState", self.saveState()) + self._browser.page().deleteLater() + super().closeEvent(event) + + def loadAddress(self): + self._browser.load(self._address.text()) + self._browser.setFocus() + + +scheme = QtWebEngineCore.QWebEngineUrlScheme(b'gemini') +scheme.setDefaultPort(1965) +scheme.setFlags(QtWebEngineCore.QWebEngineUrlScheme.SecureScheme) +QtWebEngineCore.QWebEngineUrlScheme.registerScheme(scheme) +app = QtWidgets.QApplication(sys.argv) +gem_handler = GeminiSchemeHandler() +profile = QtWebEngineCore.QWebEngineProfile() +profile.removeAllUrlSchemeHandlers() +profile.installUrlSchemeHandler(b'gemini', gem_handler) +ex = GBrowser(sys.argv[1] if len(sys.argv) > 1 else None, profile) +sys.exit(app.exec()) diff --git a/browser/fsm.py b/browser/fsm.py new file mode 100644 index 0000000..eccfc6e --- /dev/null +++ b/browser/fsm.py @@ -0,0 +1,117 @@ +import html +import sys +import urllib.parse + + +class StackFSM(object): + """ + Implement a finite state machine that uses a stack to + manage state. + """ + + def __init__(self): + self._state_stack = [] + + def _current_state(self): + if len(self._state_stack) > 0: + return self._state_stack[-1] + return None + + def update(self): + fn = self._current_state() + if fn is not None: + fn() + + def push_state(self, state): + self._state_stack.append(state) + + def pop_state(self): + return self._state_stack.pop() + + +class Parser(object): + + def __init__(self, document, output=None): + self._document = document + if output is None: + output = sys.stdout + self._output = output + self._offset = 0 + self._blanks = 0 + self._fsm = StackFSM() + + def parse(self): + self._fsm.push_state(self.text_state) + while self._fsm._current_state() is not None and len(self._document) > self._offset: + self._fsm.update() + + def text_state(self): + line = self._document[self._offset] + if line.strip() == '': + self._blanks += 1 + else: + self._blanks = 0 + if line.startswith('```'): + self._fsm.push_state(self.pre_state) + self._output.write('<pre>\n') + self._offset += 1 + elif line.startswith('* '): + self._fsm.push_state(self.list_state) + self._output.write('<ul>\n') + elif line.startswith('=>'): + self._fsm.push_state(self.link_state) + self._output.write('<ul>\n') + else: + if line.startswith('# '): + self._output.write('<h1>{}</h1>\n'.format(html.escape(line[2:]))) + elif line.startswith('## '): + self._output.write('<h2>{}</h2>\n'.format(html.escape(line[3:]))) + elif line.startswith('### '): + self._output.write('<h3>{}</h3>\n'.format(html.escape(line[4:]))) + elif line.startswith('> '): + self._output.write('<blockquote>{}</blockquote>\n'.format(html.escape(line[2:]))) + elif line.strip() == '': + if self._blanks > 1: + self._output.write('<br/>\n') + else: + self._output.write('<p>{}</p>\n'.format(html.escape(line))) + self._offset += 1 + + def pre_state(self): + line = self._document[self._offset] + if line.startswith('```'): + self._fsm.pop_state() + self._output.write('</pre>\n') + self._offset += 1 + else: + self._output.write(line + '\n') + self._offset += 1 + + def list_state(self): + line = self._document[self._offset] + if line.startswith('* '): + self._output.write('<li>{}</li>\n'.format(html.escape(line[2:]))) + self._offset += 1 + else: + self._fsm.pop_state() + self._output.write('</ul>\n') + + def link_state(self): + line = self._document[self._offset] + if line.startswith('=>'): + parts = line[2:].split(None, 1) + url = parts[0] + url_parts = urllib.parse.urlsplit(url) + if url_parts.scheme in ('gemini', ''): + external = '' + else: + external = open('external_link.svg').read() + if len(parts) == 1: + text = url + else: + text = html.escape(parts[1]) + self._output.write('<li class="link"><a href="{}">{}</a>{}</li>\n'.format(url, text, external)) + self._offset += 1 + else: + self._fsm.pop_state() + self._output.write('</ul>\n') diff --git a/browser/gemini.py b/browser/gemini.py new file mode 100644 index 0000000..7bedda5 --- /dev/null +++ b/browser/gemini.py @@ -0,0 +1,148 @@ +import io +import re +import socket +import ssl +import string +import urllib.parse + +import fsm + + +def htmlescape(text: str) -> str: + return text.replace('<', '<').replace('>', '>') + + +def gem2html(gem: dict) -> str: + params = { + 'charset': 'utf-8', + 'lang': 'en', + 'css': open('style.css').read() + } + if gem['status'][0] == '2': + template = string.Template(open('page_template.html').read()) + body = io.StringIO() + parser = fsm.Parser(gem['body'].split('\n'), body) + parser.parse() + params['body'] = body.getvalue() + elif gem['status'][0] == '1': + template = string.Template(open('input_template.html').read()) + params['meta'] = gem['meta'] + else: + template = string.Template(open('error_template.html').read()) + if gem['status'] == '00': + params['status'] = 'CLIENT ERROR' + elif gem['status'][0] == '4': + params['status'] = gem['status'] + ' TEMPORARY FAILURE' + elif gem['status'][0] == '5': + params['status'] = gem['status'] + ' PERMANENT FAILURE' + else: + params['status'] = 'UNHANDLED STATUS {}'.format(gem['status']) + params['meta'] = gem['meta'] + + html = template.substitute(params) + with open('latest.html', 'w') as fp: + fp.write(html) + return html + + +def urljoin(base: str, url: str) -> str: + if base is None: + return url + base = re.sub('^gemini:', 'http:', base) + url = re.sub('^gemini:', 'http:', url) + return re.sub('^http:', 'gemini:', urllib.parse.urljoin(base, url)) + + +def get(url: str, follow_redirects: bool = True) -> dict: + response = _get(url) + if follow_redirects is True: + count = 0 + while response['status'][0] == '3': + count += 1 + if count > 20: + return {'status': '00', 'meta': 'Too many redirects'} + print('{status} {meta}'.format(**response)) + response = _get(response['meta']) + return response + + +def hack_url(url: str) -> str: + """ + An ugly hack to reformat input queries the way gemini wants them: + ?<query> + Rather than the default way an html get form renders them: + ?<inputname>=<query> + I don't think this ever *should* break but I guess it *could*. + """ + url_parts = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(url_parts.query) + if len(query) == 1 and '__client_internal_input' in query and len(query['__client_internal_input']) == 1: + query = str(query['__client_internal_input'][0]) + url = urllib.parse.urlunsplit(( + url_parts.scheme, + url_parts.netloc, + url_parts.path, + query, + url_parts.fragment, + )) + url_parts = urllib.parse.urlsplit(url) + return url + + +def _parse_meta(meta: str) -> dict: + mime, _, params_text = meta.lower().strip().partition(';') + params = {} + if params_text.strip(): + for param in params_text.split(';'): + k, val = param.split('=') + params[k.strip()] = val.strip() + params['mime'] = mime.strip() + return params + + +def _get(url: str) -> dict: + url_parts = urllib.parse.urlsplit(url) + if len(url_parts.path) == 0: + return { + 'status': '32', + 'meta': urllib.parse.urlunsplit(( + url_parts.scheme, + url_parts.netloc, + '/', + url_parts.query, + url_parts.fragment, + )) + } + try: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + port = 1965 if url_parts.port is None else url_parts.port + with socket.create_connection((url_parts.hostname, port)) as sock: + with context.wrap_socket(sock, server_hostname=url_parts.hostname) as ssock: + ssock.sendall('{url}\r\n'.format(url=url).encode('utf8')) + fp = ssock.makefile(mode='rb') + header = fp.readline(1027) + parts = header.decode('utf8').split(None, 1) + status = parts[0] + if len(parts) == 1: + meta = '' + else: + meta = parts[1] + if status[0] != '2': + return { + 'status': status, + 'meta': meta.strip(), + } + meta_params = _parse_meta(meta) + body = fp.read() + return { + 'status': status, + 'meta': meta.strip(), + 'body': body.decode(meta_params.get('charset', 'utf8')), + } + except Exception as ex: + return { + 'status': '00', + 'meta': '{}'.format(ex), + } |