feat: add Gemini and Spartan protocol support
This commit is contained in:
parent
a4a49f41d7
commit
fa7aa5557c
48 changed files with 4992 additions and 161 deletions
|
|
@ -1,12 +1,16 @@
|
|||
import 'package:actors/actors.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:signals/signals.dart';
|
||||
|
||||
import '../../database/database.dart';
|
||||
import '../../mews/document.dart';
|
||||
import '../../protocols/tofu.dart' show TofuPin;
|
||||
import '../actors/fetch_actor.dart';
|
||||
import '../internal_actions.dart';
|
||||
import '../internal_pages.dart';
|
||||
import '../key_store.dart';
|
||||
import '../pages/mews_urls.dart';
|
||||
|
||||
/// What went wrong while browsing. The presentation layer maps these to
|
||||
|
|
@ -26,6 +30,52 @@ class BrokenPage extends BrowseError {
|
|||
final String source;
|
||||
}
|
||||
|
||||
/// A protocol status with the site's own message for the reader: a temporary
|
||||
/// or permanent failure, a client or server error.
|
||||
class ProtocolFailure extends BrowseError {
|
||||
const ProtocolFailure(this.message);
|
||||
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// Redirects went around more times than the transports follow.
|
||||
class RedirectLoop extends BrowseError {
|
||||
const RedirectLoop();
|
||||
}
|
||||
|
||||
/// A body of a type the reader cannot show.
|
||||
class UnsupportedPage extends BrowseError {
|
||||
const UnsupportedPage(this.mime);
|
||||
|
||||
final String mime;
|
||||
}
|
||||
|
||||
/// The site asked for a client certificate (Gemini 6x). Choosing an identity
|
||||
/// is the reader's decision, made on a page of its own.
|
||||
class ClientCertificateRequired extends BrowseError {
|
||||
const ClientCertificateRequired({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.status,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final int status;
|
||||
final String meta;
|
||||
}
|
||||
|
||||
/// The site's certificate is not the one pinned for it, and the pin has not
|
||||
/// expired. The reader can still choose to trust what was presented.
|
||||
class CertificateChanged extends BrowseError {
|
||||
const CertificateChanged({required this.host, required this.port, required this.presented});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final TofuPin presented;
|
||||
}
|
||||
|
||||
/// Coordinates navigation: owns the signals the reader UI observes and
|
||||
/// dispatches fetch commands to the fetch actor. Local-first: a cached page is
|
||||
/// revalidated with the server but survives any network failure.
|
||||
|
|
@ -33,15 +83,18 @@ class BrowserController {
|
|||
BrowserController({
|
||||
required MewsDatabase db,
|
||||
required Actor<FetchCommand, FetchResult> fetch,
|
||||
required KeyStore keys,
|
||||
InternalPages? internalPages,
|
||||
InternalActions? internalActions,
|
||||
}) : _db = db,
|
||||
_fetch = fetch,
|
||||
_keys = keys,
|
||||
_internalPages = internalPages,
|
||||
_internalActions = internalActions;
|
||||
|
||||
final MewsDatabase _db;
|
||||
final Actor<FetchCommand, FetchResult> _fetch;
|
||||
final KeyStore _keys;
|
||||
final InternalPages? _internalPages;
|
||||
final InternalActions? _internalActions;
|
||||
|
||||
|
|
@ -108,13 +161,19 @@ class BrowserController {
|
|||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
|
||||
// Mews sites commonly live on a wap. subdomain; try that before showing
|
||||
// an error when the address as typed does not work out.
|
||||
if (error.value != null && _stack[_stackIndex] == url) {
|
||||
final wap = wapUrlOf(url);
|
||||
// The address as typed is only the reader's first guess at where a site
|
||||
// lives. A Mews site commonly lives on a wap. subdomain, and a hostname
|
||||
// that answers on neither the web nor its wap. subdomain may still be a
|
||||
// capsule: gemini is tried next and spartan last. Only an address typed
|
||||
// without a scheme is offered the capsule guesses, because an explicit
|
||||
// scheme is the reader naming the place to go.
|
||||
var candidate = url;
|
||||
if (error.value != null && _stack[_stackIndex] == candidate) {
|
||||
final wap = wapUrlOf(candidate);
|
||||
if (wap != null) {
|
||||
final firstError = error.value;
|
||||
_stack[_stackIndex] = wap;
|
||||
candidate = wap;
|
||||
_stack[_stackIndex] = candidate;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
// The address as typed is the closer match; keep its error when the
|
||||
|
|
@ -124,6 +183,26 @@ class BrowserController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.value != null && _stack[_stackIndex] == candidate) {
|
||||
final typed = input.trim();
|
||||
final web = Uri.tryParse(url);
|
||||
if (web != null && web.host.isNotEmpty && !_schemePattern.hasMatch(typed)) {
|
||||
final firstError = error.value;
|
||||
for (final scheme in ['gemini', 'spartan']) {
|
||||
candidate = Uri(scheme: scheme, host: web.host, path: web.path).toString();
|
||||
_stack[_stackIndex] = candidate;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
if (error.value == null) return;
|
||||
}
|
||||
// Nothing answered; the web address is the closest guess the reader
|
||||
// typed, so it is what the address bar shows with the first error.
|
||||
_stack[_stackIndex] = url;
|
||||
_updateNeighbors();
|
||||
if (firstError != null) error.value = firstError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> back() async {
|
||||
|
|
@ -195,6 +274,31 @@ class BrowserController {
|
|||
_stackIndex++;
|
||||
_updateNeighbors();
|
||||
_show(page);
|
||||
// A form on a protocol page, like an input request, answers with a
|
||||
// page of the protocol's own, which joins the walk like any fetched
|
||||
// page so going back returns to the question.
|
||||
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified, :final pinUpdates):
|
||||
for (final update in pinUpdates) {
|
||||
await _db.savePin(update.host, update.port, update.pin);
|
||||
}
|
||||
_stack.removeRange(_stackIndex + 1, _stack.length);
|
||||
_stack.add(page.url);
|
||||
_stackIndex++;
|
||||
_updateNeighbors();
|
||||
_show(page);
|
||||
if (!notModified && source.isNotEmpty) {
|
||||
await _db.saveCachedPage(CachedPagesCompanion.insert(
|
||||
url: page.url,
|
||||
source: source,
|
||||
title: Value(page.title),
|
||||
etag: Value(etag),
|
||||
lastModified: Value(lastModified),
|
||||
));
|
||||
}
|
||||
case FetchProtocolError(:final message):
|
||||
error.value = ProtocolFailure(message);
|
||||
case FetchCertificateChanged(:final host, :final port, :final presented):
|
||||
error.value = CertificateChanged(host: host, port: port, presented: presented);
|
||||
case _:
|
||||
error.value = const LoadFailed();
|
||||
}
|
||||
|
|
@ -264,23 +368,33 @@ class BrowserController {
|
|||
}
|
||||
|
||||
final cached = await _db.cachedPage(url);
|
||||
final uri = Uri.tryParse(url);
|
||||
final credentials = await _geminiCredentials(uri);
|
||||
final result = await _fetch.send(FetchPage(
|
||||
url: url,
|
||||
etag: cached?.etag,
|
||||
lastModified: cached?.lastModified,
|
||||
cachedSource: cached?.source,
|
||||
pins: credentials.pins,
|
||||
clientCert: credentials.clientCert,
|
||||
clientKey: credentials.clientKey,
|
||||
));
|
||||
loading.value = false;
|
||||
|
||||
switch (result) {
|
||||
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified):
|
||||
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified, :final pinUpdates):
|
||||
currentPage.value = page;
|
||||
error.value = null;
|
||||
visitedUrls.value = {...visitedUrls.value, url};
|
||||
// Opening a post is reading it. Nothing happens when the address is
|
||||
// not one, so this needs no knowledge of the reader's subscriptions.
|
||||
await _db.markEntryRead(url);
|
||||
if (!notModified) {
|
||||
for (final update in pinUpdates) {
|
||||
await _db.savePin(update.host, update.port, update.pin);
|
||||
}
|
||||
// An empty source is a body with no text worth keeping, like an
|
||||
// image; there is nothing to cache.
|
||||
if (!notModified && source.isNotEmpty) {
|
||||
await _db.saveCachedPage(CachedPagesCompanion.insert(
|
||||
url: url,
|
||||
source: source,
|
||||
|
|
@ -293,12 +407,81 @@ class BrowserController {
|
|||
case FetchParseError(:final source):
|
||||
currentPage.value = null;
|
||||
error.value = BrokenPage(source);
|
||||
case FetchProtocolError(:final message):
|
||||
currentPage.value = null;
|
||||
error.value = ProtocolFailure(message);
|
||||
case FetchRedirectLoop():
|
||||
currentPage.value = null;
|
||||
error.value = const RedirectLoop();
|
||||
case FetchUnsupportedType(:final mime):
|
||||
currentPage.value = null;
|
||||
error.value = UnsupportedPage(mime);
|
||||
case FetchCertificateChanged(:final host, :final port, :final presented):
|
||||
currentPage.value = null;
|
||||
error.value = CertificateChanged(host: host, port: port, presented: presented);
|
||||
case FetchClientCertificateRequired(:final status, :final meta):
|
||||
currentPage.value = null;
|
||||
error.value = ClientCertificateRequired(
|
||||
host: uri?.host ?? '', port: uri != null && uri.hasPort ? uri.port : 1965,
|
||||
status: status, meta: meta);
|
||||
case _:
|
||||
currentPage.value = null;
|
||||
error.value = const LoadFailed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the certificate the error page is showing as the new pin and
|
||||
/// reloads. Only the reader can make this choice, from the error page.
|
||||
Future<void> trustPresentedCertificate() async {
|
||||
final shown = error.value;
|
||||
if (shown is! CertificateChanged) return;
|
||||
await _db.savePin(shown.host, shown.port, shown.presented);
|
||||
await _loadCurrent();
|
||||
}
|
||||
|
||||
/// Binds the certificate the reader picked to the site asking for one and
|
||||
/// reloads, so the site sees the identity on the connection that follows.
|
||||
Future<void> chooseCertificate(int certificateId) async {
|
||||
final shown = error.value;
|
||||
if (shown is! ClientCertificateRequired) return;
|
||||
await _db.bindCertificate(shown.host, shown.port, certificateId);
|
||||
await _loadCurrent();
|
||||
}
|
||||
|
||||
/// The raw bytes of a body on a protocol page, for the renderer to show an
|
||||
/// image. There is no address the platform could load it from, so it comes
|
||||
/// through the fetch actor wearing the same trust and identity as the page.
|
||||
Future<Uint8List?> fetchImageBytes(String url) async {
|
||||
final credentials = await _geminiCredentials(Uri.tryParse(url));
|
||||
final result = await _fetch.send(FetchBytes(
|
||||
url: url,
|
||||
pins: credentials.pins,
|
||||
clientCert: credentials.clientCert,
|
||||
clientKey: credentials.clientKey,
|
||||
));
|
||||
return result is FetchBytesOk ? result.data : null;
|
||||
}
|
||||
|
||||
/// What a fetch of [uri] over gemini needs: the reader's pinned hosts,
|
||||
/// which live on this isolate's database and not in the actor's, and the
|
||||
/// identity bound to this host, worn only there.
|
||||
Future<({Map<String, TofuPin> pins, String? clientCert, String? clientKey})>
|
||||
_geminiCredentials(Uri? uri) async {
|
||||
if (uri == null || uri.scheme != 'gemini') {
|
||||
return (pins: const <String, TofuPin>{}, clientCert: null, clientKey: null);
|
||||
}
|
||||
final pins = await _db.allPins();
|
||||
final port = uri.hasPort ? uri.port : 1965;
|
||||
final bound = await _db.bindingFor(uri.host, port);
|
||||
if (bound == null) return (pins: pins, clientCert: null, clientKey: null);
|
||||
final held = await _db.clientCertificate(bound);
|
||||
return (
|
||||
pins: pins,
|
||||
clientCert: held?.certPem,
|
||||
clientKey: await _keys.read(bound),
|
||||
);
|
||||
}
|
||||
|
||||
void _show(MewsPage page) {
|
||||
currentPage.value = page;
|
||||
_db.recordVisit(page.url, page.title);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue