the-mews/app/lib/application/controllers/browser_controller.dart

520 lines
19 KiB
Dart

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
/// user-facing messages; no raw codes cross into the UI.
sealed class BrowseError {
const BrowseError();
}
class LoadFailed extends BrowseError {
const LoadFailed();
}
class BrokenPage extends BrowseError {
const BrokenPage(this.source);
/// The markup that failed to parse; "show page text" recovers its content.
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.
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;
/// The stack of visited pages; the reader is at [_stackIndex].
final List<String> _stack = [];
int _stackIndex = -1;
final currentPage = signal<MewsPage?>(null);
final loading = signal(false);
final error = signal<BrowseError?>(null);
/// Pages opened this session, so links can show the visited color.
final visitedUrls = signal(<String>{});
/// The parsed pages next to the current one in the stack, ready to show
/// while swiping between them.
final backPage = signal<MewsPage?>(null);
final forwardPage = signal<MewsPage?>(null);
/// The address the reader is standing on, which an error page speaks about.
/// Empty when there is nowhere to be yet.
String get currentAddress => _stackIndex >= 0 ? _stack[_stackIndex] : '';
bool get canGoBack => _stackIndex > 0;
bool get canGoForward => _stackIndex >= 0 && _stack.length - 1 > _stackIndex;
/// Normalizes what the reader typed into an address: bare domains become
/// https URLs; anything already carrying a scheme (mews:, https:, ...) is
/// left alone.
static String normalizeAddress(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return '';
if (_schemePattern.hasMatch(trimmed)) return trimmed;
return 'https://$trimmed';
}
static final _schemePattern = RegExp(r'^[a-zA-Z][a-zA-Z0-9+.-]*:');
/// The wap. subdomain equivalent of [url], or null when the host already
/// starts with it or the address is not a plain web URL.
static String? wapUrlOf(String url) {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null;
if (uri.scheme != 'http' && uri.scheme != 'https') return null;
if (uri.host.startsWith('wap.')) return null;
return uri.replace(host: 'wap.${uri.host}').toString();
}
Future<void> open(String input) async {
final url = MewsUrls.canonical(normalizeAddress(input));
if (url.isEmpty) return;
// Action links change something and stay on the current page instead of
// joining the walk history.
if (MewsUrls.isAction(url)) {
// Only the client's own pages may change the reader's data. A fetched
// page linking to mews:do/... must not act on their behalf (spec 8.1).
if (!_onInternalPage) return;
if (await (_internalActions?.apply(url) ?? Future.value(false))) {
await _loadCurrent();
}
return;
}
_stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(url);
_stackIndex++;
_updateNeighbors();
await _loadCurrent();
// 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;
candidate = wap;
_stack[_stackIndex] = candidate;
_updateNeighbors();
await _loadCurrent();
// The address as followed is the closer match: when the wap.
// attempt does not work either, its error is the one to show and the
// stack goes back to the address the reader followed, so the
// address bar and anything acting on the failure (like opening the
// page in a web browser) speak of the real address, not the guess.
if (error.value != null && firstError != null) {
error.value = firstError;
candidate = url;
_stack[_stackIndex] = url;
_updateNeighbors();
}
}
}
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 {
if (!canGoBack) return;
_stackIndex--;
// The neighbor was parsed for the swipe already; show it right away so
// the transition lands on content instead of a blank page while
// _loadCurrent revalidates it against the server.
_showParsedNeighbor(backPage.value);
_updateNeighbors();
await _loadCurrent();
}
Future<void> forward() async {
if (!canGoForward) return;
_stackIndex++;
_showParsedNeighbor(forwardPage.value);
_updateNeighbors();
await _loadCurrent();
}
void _showParsedNeighbor(MewsPage? page) {
if (page == null) return;
currentPage.value = page;
error.value = null;
visitedUrls.value = {...visitedUrls.value, page.url};
}
Future<void> reload() => _loadCurrent();
/// Whether the reader is standing on one of the client's own pages.
bool get _onInternalPage =>
_stackIndex >= 0 && MewsUrls.isInternal(_stack[_stackIndex]);
/// Rebuilds the open page when it is one of ours, so a change made elsewhere
/// shows up without the reader navigating.
Future<void> refreshInternalPage() async {
if (!_onInternalPage) return;
await _loadCurrent();
}
/// Narrows the open page to what the reader has typed in a search box. The
/// query rides in the address as it does when the box is submitted, but it
/// replaces the step of the walk they are standing on instead of adding
/// one, so a search of eight letters is one step back and not eight.
///
/// Only one of our own pages narrows this way. A form on a fetched page is
/// sent when it is submitted, never while it is being filled in, and so is
/// a form that changes something rather than going somewhere.
Future<void> narrowWhileTyping(String action, Map<String, String> fields) async {
if (!MewsUrls.isInternal(action) || MewsUrls.isAction(action)) return;
if (_stackIndex < 0) return;
final url = MewsUrls.page(Uri.parse(action), fields);
if (url == _stack[_stackIndex]) return;
// The page the reader was going to next belonged to the search they have
// now changed, so it is dropped as it is on any other navigation.
_stack.removeRange(_stackIndex + 1, _stack.length);
_stack[_stackIndex] = url;
_updateNeighbors();
await _loadCurrent();
}
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
// A form on one of our own pages is a navigation or an action, never a
// network request; the fetch client cannot speak the mews scheme.
if (MewsUrls.isInternal(action)) {
final base = Uri.parse(action);
final verb = MewsUrls.verbOf(action);
return open(verb == null
? MewsUrls.page(base, fields)
: MewsUrls.action(verb, {...base.queryParameters, ...fields}));
}
loading.value = true;
error.value = null;
final FetchResult result;
try {
result = await _fetch.send(SubmitForm(url: action, method: method, fields: fields));
} catch (_) {
// A submit that throws out of the actor is still just a failed load.
loading.value = false;
error.value = const LoadFailed();
return;
}
loading.value = false;
switch (result) {
case FetchFormOk(:final page):
_stack.removeRange(_stackIndex + 1, _stack.length);
_stack.add(page.url);
_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();
}
}
/// Reopens the most recent page from the previous session, or the hub when
/// there is nothing to return to.
Future<void> restoreLastSession() async {
final url = await _db.lastVisitedUrl() ?? MewsUrls.hub;
_stack.add(url);
_stackIndex = 0;
_updateNeighbors();
await _loadCurrent();
}
/// Parses the stack's neighboring pages in the fetch actor so swipes can
/// show the real content. Results arriving after another navigation are
/// dropped, so rapid navigation never shows a stale neighbor.
void _updateNeighbors() {
_loadNeighbor(_stackIndex - 1, backPage);
_loadNeighbor(_stackIndex + 1, forwardPage);
}
Future<void> _loadNeighbor(int index, Signal<MewsPage?> target) async {
final expectedIndex = _stackIndex;
if (index < 0 || index >= _stack.length) {
target.value = null;
return;
}
final url = _stack[index];
if (_internalPages?.handles(url) ?? false) {
target.value = await _internalPages!.pageFor(url);
return;
}
final cached = await _db.cachedPage(url);
if (_stackIndex != expectedIndex) return;
if (cached == null) {
target.value = null;
return;
}
final result = await _fetch.send(ParseSource(url: url, source: cached.source));
if (_stackIndex != expectedIndex) return;
target.value = result is FetchPageOk ? result.page : null;
}
Future<void> _loadCurrent() async {
if (_stackIndex < 0) return;
final url = _stack[_stackIndex];
loading.value = true;
error.value = null;
// Internal pages are built from the local database, never fetched or
// cached; they join the stack like any other page.
if (_internalPages?.handles(url) ?? false) {
final page = await _internalPages!.pageFor(url);
loading.value = false;
if (page != null) {
currentPage.value = page;
error.value = null;
// Our own addresses stay out of visitedUrls: a sort or filter link
// would otherwise take the visited color after one use.
} else {
currentPage.value = null;
error.value = const LoadFailed();
}
return;
}
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, :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);
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,
title: Value(page.title),
etag: Value(etag),
lastModified: Value(lastModified),
));
await _db.recordVisit(url, page.title);
}
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);
}
}