summaryrefslogtreecommitdiff
path: root/browser
diff options
context:
space:
mode:
Diffstat (limited to 'browser')
-rwxr-xr-xbrowser/browser.py133
-rw-r--r--browser/fsm.py117
-rw-r--r--browser/gemini.py148
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('<', '&lt;').replace('>', '&gt;')
+
+
+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),
+ }