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 fetch, required KeyStore keys, InternalPages? internalPages, InternalActions? internalActions, }) : _db = db, _fetch = fetch, _keys = keys, _internalPages = internalPages, _internalActions = internalActions; final MewsDatabase _db; final Actor _fetch; final KeyStore _keys; final InternalPages? _internalPages; final InternalActions? _internalActions; /// The stack of visited pages; the reader is at [_stackIndex]. final List _stack = []; int _stackIndex = -1; final currentPage = signal(null); final loading = signal(false); final error = signal(null); /// Pages opened this session, so links can show the visited color. final visitedUrls = signal({}); /// The parsed pages next to the current one in the stack, ready to show /// while swiping between them. final backPage = signal(null); final forwardPage = signal(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 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 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 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 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 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 narrowWhileTyping(String action, Map 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 submitForm(String action, String method, Map 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 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 _loadNeighbor(int index, Signal 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 _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 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 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 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 pins, String? clientCert, String? clientKey})> _geminiCredentials(Uri? uri) async { if (uri == null || uri.scheme != 'gemini') { return (pins: const {}, 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); } }