2026-10-11 09:33:25 +03:00
|
|
|
import 'package:actors/actors.dart';
|
|
|
|
|
import 'package:drift/drift.dart' show Value;
|
|
|
|
|
import 'package:signals/signals.dart';
|
|
|
|
|
|
|
|
|
|
import '../../database/database.dart';
|
|
|
|
|
import '../../mews/document.dart';
|
|
|
|
|
import '../actors/fetch_actor.dart';
|
2026-10-11 13:05:07 +03:00
|
|
|
import '../internal_actions.dart';
|
2026-10-11 11:48:24 +03:00
|
|
|
import '../internal_pages.dart';
|
2026-10-11 13:05:07 +03:00
|
|
|
import '../pages/mews_urls.dart';
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
2026-10-11 11:48:24 +03:00
|
|
|
BrowserController({
|
|
|
|
|
required MewsDatabase db,
|
|
|
|
|
required Actor<FetchCommand, FetchResult> fetch,
|
|
|
|
|
InternalPages? internalPages,
|
2026-10-11 13:05:07 +03:00
|
|
|
InternalActions? internalActions,
|
2026-10-11 11:48:24 +03:00
|
|
|
}) : _db = db,
|
|
|
|
|
_fetch = fetch,
|
2026-10-11 13:05:07 +03:00
|
|
|
_internalPages = internalPages,
|
|
|
|
|
_internalActions = internalActions;
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
final MewsDatabase _db;
|
|
|
|
|
final Actor<FetchCommand, FetchResult> _fetch;
|
2026-10-11 11:48:24 +03:00
|
|
|
final InternalPages? _internalPages;
|
2026-10-11 13:05:07 +03:00
|
|
|
final InternalActions? _internalActions;
|
2026-10-11 09:33:25 +03:00
|
|
|
|
|
|
|
|
/// 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>{});
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
/// 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);
|
2026-10-11 10:38:50 +03:00
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
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
|
2026-10-11 11:48:24 +03:00
|
|
|
/// https URLs; anything already carrying a scheme (mews:, https:, ...) is
|
|
|
|
|
/// left alone.
|
2026-10-11 09:33:25 +03:00
|
|
|
static String normalizeAddress(String input) {
|
|
|
|
|
final trimmed = input.trim();
|
|
|
|
|
if (trimmed.isEmpty) return '';
|
2026-10-11 11:48:24 +03:00
|
|
|
if (_schemePattern.hasMatch(trimmed)) return trimmed;
|
2026-10-11 09:33:25 +03:00
|
|
|
return 'https://$trimmed';
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:48:24 +03:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<void> open(String input) async {
|
2026-10-11 13:05:07 +03:00
|
|
|
final url = MewsUrls.canonical(normalizeAddress(input));
|
2026-10-11 09:33:25 +03:00
|
|
|
if (url.isEmpty) return;
|
2026-10-11 12:14:32 +03:00
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
// Action links change something and stay on the current page instead of
|
2026-10-11 12:14:32 +03:00
|
|
|
// joining the walk history.
|
2026-10-11 13:05:07 +03:00
|
|
|
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();
|
|
|
|
|
}
|
2026-10-11 12:14:32 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
_stack.removeRange(_stackIndex + 1, _stack.length);
|
|
|
|
|
_stack.add(url);
|
|
|
|
|
_stackIndex++;
|
2026-10-11 11:05:03 +03:00
|
|
|
_updateNeighbors();
|
2026-10-11 09:33:25 +03:00
|
|
|
await _loadCurrent();
|
2026-10-11 11:48:24 +03:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
if (wap != null) {
|
|
|
|
|
final firstError = error.value;
|
|
|
|
|
_stack[_stackIndex] = wap;
|
|
|
|
|
_updateNeighbors();
|
|
|
|
|
await _loadCurrent();
|
|
|
|
|
// The address as typed is the closer match; keep its error when the
|
|
|
|
|
// wap. attempt does not work either.
|
|
|
|
|
if (error.value != null && firstError != null) {
|
|
|
|
|
error.value = firstError;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-11 09:33:25 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> back() async {
|
|
|
|
|
if (!canGoBack) return;
|
|
|
|
|
_stackIndex--;
|
2026-10-11 11:05:03 +03:00
|
|
|
// 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();
|
2026-10-11 09:33:25 +03:00
|
|
|
await _loadCurrent();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> forward() async {
|
|
|
|
|
if (!canGoForward) return;
|
|
|
|
|
_stackIndex++;
|
2026-10-11 11:05:03 +03:00
|
|
|
_showParsedNeighbor(forwardPage.value);
|
|
|
|
|
_updateNeighbors();
|
2026-10-11 09:33:25 +03:00
|
|
|
await _loadCurrent();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
void _showParsedNeighbor(MewsPage? page) {
|
|
|
|
|
if (page == null) return;
|
|
|
|
|
currentPage.value = page;
|
|
|
|
|
error.value = null;
|
|
|
|
|
visitedUrls.value = {...visitedUrls.value, page.url};
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<void> reload() => _loadCurrent();
|
|
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
/// 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
|
2026-10-11 13:05:07 +03:00
|
|
|
// 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}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
loading.value = true;
|
|
|
|
|
error.value = null;
|
2026-10-11 13:05:07 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-10-11 09:33:25 +03:00
|
|
|
loading.value = false;
|
|
|
|
|
switch (result) {
|
|
|
|
|
case FetchFormOk(:final page):
|
|
|
|
|
_stack.removeRange(_stackIndex + 1, _stack.length);
|
|
|
|
|
_stack.add(page.url);
|
|
|
|
|
_stackIndex++;
|
2026-10-11 11:05:03 +03:00
|
|
|
_updateNeighbors();
|
2026-10-11 09:33:25 +03:00
|
|
|
_show(page);
|
|
|
|
|
case _:
|
|
|
|
|
error.value = const LoadFailed();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:05:07 +03:00
|
|
|
/// Reopens the most recent page from the previous session, or the hub when
|
|
|
|
|
/// there is nothing to return to.
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<void> restoreLastSession() async {
|
2026-10-11 13:05:07 +03:00
|
|
|
final url = await _db.lastVisitedUrl() ?? MewsUrls.hub;
|
2026-10-11 09:33:25 +03:00
|
|
|
_stack.add(url);
|
|
|
|
|
_stackIndex = 0;
|
2026-10-11 11:05:03 +03:00
|
|
|
_updateNeighbors();
|
2026-10-11 09:33:25 +03:00
|
|
|
await _loadCurrent();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
/// 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);
|
|
|
|
|
}
|
2026-10-11 10:38:50 +03:00
|
|
|
|
2026-10-11 11:05:03 +03:00
|
|
|
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];
|
2026-10-11 11:48:24 +03:00
|
|
|
if (_internalPages?.handles(url) ?? false) {
|
|
|
|
|
target.value = await _internalPages!.pageFor(url);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-11 11:05:03 +03:00
|
|
|
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;
|
2026-10-11 10:38:50 +03:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
Future<void> _loadCurrent() async {
|
|
|
|
|
if (_stackIndex < 0) return;
|
|
|
|
|
final url = _stack[_stackIndex];
|
|
|
|
|
loading.value = true;
|
|
|
|
|
error.value = null;
|
|
|
|
|
|
2026-10-11 11:48:24 +03:00
|
|
|
// 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;
|
2026-10-11 13:05:07 +03:00
|
|
|
// Our own addresses stay out of visitedUrls: a sort or filter link
|
|
|
|
|
// would otherwise take the visited color after one use.
|
2026-10-11 11:48:24 +03:00
|
|
|
} else {
|
|
|
|
|
currentPage.value = null;
|
|
|
|
|
error.value = const LoadFailed();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 09:33:25 +03:00
|
|
|
final cached = await _db.cachedPage(url);
|
|
|
|
|
final result = await _fetch.send(FetchPage(
|
|
|
|
|
url: url,
|
|
|
|
|
etag: cached?.etag,
|
|
|
|
|
lastModified: cached?.lastModified,
|
|
|
|
|
cachedSource: cached?.source,
|
|
|
|
|
));
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
|
|
|
|
switch (result) {
|
|
|
|
|
case FetchPageOk(:final page, :final source, :final etag, :final lastModified, :final notModified):
|
|
|
|
|
currentPage.value = page;
|
|
|
|
|
error.value = null;
|
|
|
|
|
visitedUrls.value = {...visitedUrls.value, url};
|
|
|
|
|
if (!notModified) {
|
|
|
|
|
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 _:
|
|
|
|
|
currentPage.value = null;
|
|
|
|
|
error.value = const LoadFailed();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _show(MewsPage page) {
|
|
|
|
|
currentPage.value = page;
|
|
|
|
|
_db.recordVisit(page.url, page.title);
|
|
|
|
|
}
|
|
|
|
|
}
|