feat: add Gemini and Spartan protocol support

This commit is contained in:
randogoth 2026-10-11 22:07:25 +03:00
parent a4a49f41d7
commit fa7aa5557c
48 changed files with 4992 additions and 161 deletions

View file

@ -3,8 +3,12 @@ import 'dart:async' show unawaited;
import 'dart:ui' show PlatformDispatcher;
import 'package:actors/actors.dart';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../application/actors/fetch_actor.dart';
@ -13,8 +17,9 @@ import '../application/controllers/browser_controller.dart';
import '../application/controllers/reader_settings_controller.dart';
import '../application/controllers/subscriptions_controller.dart';
import '../application/fonts.dart';
import '../application/internal_actions.dart';
import '../application/internal_actions.dart' show InternalActions, PickedPem;
import '../application/internal_pages.dart';
import '../application/key_store.dart';
import '../database/database.dart';
import '../l10n/locales.dart';
import '../l10n/strings.dart';
@ -29,12 +34,19 @@ class AppDependencies {
MewsDatabase? database,
Actor<FetchCommand, FetchResult>? fetch,
Strings? strings,
KeyStore? keys,
Future<PickedPem?> Function(String title)? pickPemFile,
}) : db = database ?? MewsDatabase(),
fetch = fetch ?? Actor.create(FetchHandler.new),
strings = strings ?? Strings() {
settings = ReaderSettingsController(db);
bookmarks = BookmarksController(db);
subscriptions = SubscriptionsController(db: db, fetch: this.fetch);
this.keys = keys ??
KeyStore(() async {
final support = await getApplicationSupportDirectory();
return Directory(p.join(support.path, 'keys'));
});
internalPages = InternalPages(
db: db,
settings: settings,
@ -46,10 +58,14 @@ class AppDependencies {
settings: settings,
bookmarks: bookmarks,
subscriptions: subscriptions,
keys: this.keys,
strings: this.strings,
pickPemFile: pickPemFile,
);
browser = BrowserController(
db: db,
fetch: this.fetch,
keys: this.keys,
internalPages: internalPages,
internalActions: internalActions,
);
@ -63,6 +79,7 @@ class AppDependencies {
final MewsDatabase db;
final Actor<FetchCommand, FetchResult> fetch;
late final KeyStore keys;
late final BrowserController browser;
late final ReaderSettingsController settings;
late final BookmarksController bookmarks;

View file

@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
@ -28,8 +29,10 @@ class _AddressBarState extends State<AddressBar> {
if (_focus.hasFocus != _focused) {
setState(() => _focused = _focus.hasFocus);
if (_focused) {
_controller.selection =
TextSelection(baseOffset: 0, extentOffset: _controller.text.length);
_controller.selection = TextSelection(
baseOffset: 0,
extentOffset: _controller.text.length,
);
}
}
});
@ -47,117 +50,126 @@ class _AddressBarState extends State<AddressBar> {
final deps = AppScope.of(context).deps;
final strings = AppScope.of(context).strings;
return SignalBuilder(builder: (context) {
final page = deps.browser.currentPage.value;
final url = page?.url;
if (url != null && !_focused) {
_controller.text = url;
}
return SignalBuilder(
builder: (context) {
final page = deps.browser.currentPage.value;
final url = page?.url;
if (url != null && !_focused) {
_controller.text = url;
}
// Touch devices navigate with swipes; desktops get buttons.
final hasButtons = switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
// Touch devices navigate with swipes; desktops get buttons.
final hasButtons = switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
// Only pages with dated entry links or an Atom feed can be subscribed
// to (mews spec, section 6).
final canSubscribe = page != null &&
!MewsUrls.isInternal(page.url) &&
(page.atomFeedHref != null || page.entries.isNotEmpty);
final canBookmark = page != null && !MewsUrls.isInternal(page.url);
// Only pages with dated entry links or an Atom feed can be subscribed
// to (mews spec, section 6).
final canSubscribe =
page != null &&
!MewsUrls.isInternal(page.url) &&
(page.atomFeedHref != null || page.entries.isNotEmpty);
final canBookmark = page != null && !MewsUrls.isInternal(page.url);
// One button, two layouts: on pointer platforms it sits beside the
// back and forward buttons.
final hubButton = IconButton(
icon: const Icon(Icons.menu),
tooltip: strings.t('reader-open-hub'),
onPressed: () => deps.browser.open(MewsUrls.hub),
);
// One button, two layouts: on pointer platforms it sits beside the
// back and forward buttons.
final hubButton = IconButton(
icon: const Icon(Icons.menu),
tooltip: strings.t('reader-open-hub'),
onPressed: () => deps.browser.open(MewsUrls.hub),
);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: TextField(
controller: _controller,
focusNode: _focus,
autofocus: false,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (value) {
_focus.unfocus();
deps.browser.open(value);
},
decoration: InputDecoration(
hintText: strings.t('reader-address-placeholder'),
labelText: strings.t('reader-address-label'),
isDense: true,
border: const OutlineInputBorder(),
prefixIcon: hasButtons
? Row(
mainAxisSize: MainAxisSize.min,
children: [
hubButton,
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
// The label is for screen readers only: the placeholder is what a
// sighted reader sees, and a visible labelText would repeat it.
child: Semantics(
label: strings.t('reader-address-label'),
textField: true,
child: TextField(
controller: _controller,
focusNode: _focus,
autofocus: false,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.go,
autocorrect: false,
enableSuggestions: false,
onSubmitted: (value) {
_focus.unfocus();
deps.browser.open(value);
},
decoration: InputDecoration(
hintText: strings.t('reader-address-placeholder'),
isDense: true,
border: const OutlineInputBorder(),
prefixIcon: hasButtons
? Row(
mainAxisSize: MainAxisSize.min,
children: [
hubButton,
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: strings.t('reader-back'),
onPressed: deps.browser.canGoBack
? deps.browser.back
: null,
),
IconButton(
icon: const Icon(Icons.arrow_forward),
tooltip: strings.t('reader-forward'),
onPressed: deps.browser.canGoForward
? deps.browser.forward
: null,
),
],
)
: hubButton,
prefixIconConstraints: hasButtons
? const BoxConstraints(minWidth: 138, minHeight: 48)
: null,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: strings.t('reader-reload'),
onPressed: () => deps.browser.reload(),
),
if (canSubscribe)
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: strings.t('reader-back'),
onPressed: deps.browser.canGoBack
? deps.browser.back
: null,
icon: Icon(
deps.subscriptions.isSubscribed(page.url)
? Icons.notifications_active
: Icons.notifications_outlined,
),
tooltip: deps.subscriptions.isSubscribed(page.url)
? strings.t('reader-unsubscribe')
: strings.t('reader-subscribe'),
onPressed: () => deps.subscriptions.toggle(page),
),
if (canBookmark)
IconButton(
icon: const Icon(Icons.arrow_forward),
tooltip: strings.t('reader-forward'),
onPressed: deps.browser.canGoForward
? deps.browser.forward
: null,
icon: Icon(
deps.bookmarks.isBookmarked(page.url)
? Icons.star
: Icons.star_border,
),
tooltip: deps.bookmarks.isBookmarked(page.url)
? strings.t('reader-bookmark-remove')
: strings.t('reader-bookmark-add'),
onPressed: () =>
deps.bookmarks.toggle(page.url, page.title),
),
],
)
: hubButton,
prefixIconConstraints: hasButtons
? const BoxConstraints(minWidth: 138, minHeight: 48)
: null,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.refresh),
tooltip: strings.t('reader-reload'),
onPressed: () => deps.browser.reload(),
],
),
if (canSubscribe)
IconButton(
icon: Icon(
deps.subscriptions.isSubscribed(page.url)
? Icons.notifications_active
: Icons.notifications_outlined,
),
tooltip: deps.subscriptions.isSubscribed(page.url)
? strings.t('reader-unsubscribe')
: strings.t('reader-subscribe'),
onPressed: () => deps.subscriptions.toggle(page),
),
if (canBookmark)
IconButton(
icon: Icon(
deps.bookmarks.isBookmarked(page.url)
? Icons.star
: Icons.star_border,
),
tooltip: deps.bookmarks.isBookmarked(page.url)
? strings.t('reader-bookmark-remove')
: strings.t('reader-bookmark-add'),
onPressed: () => deps.bookmarks.toggle(page.url, page.title),
),
],
),
),
),
),
);
});
);
},
);
}
}

View file

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'dart:ui' as ui show TextDirection;
import 'package:flutter/gestures.dart';
@ -20,6 +22,7 @@ class MewsPageView extends StatefulWidget {
required this.visitedUrls,
required this.onLinkTap,
required this.onFormSubmit,
this.fetchImageBytes,
this.scrollController,
this.ownPage = false,
super.key,
@ -32,6 +35,10 @@ class MewsPageView extends StatefulWidget {
final Set<String> visitedUrls;
final void Function(String url) onLinkTap;
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
/// The bytes of an image living on a protocol page, where no address exists
/// that the platform could load from. Null when the page holds none.
final Future<Uint8List?> Function(String url)? fetchImageBytes;
final ScrollController? scrollController;
/// Whether this is one of the client's own pages. Those are built from the
@ -47,6 +54,10 @@ class MewsPageView extends StatefulWidget {
class _MewsPageViewState extends State<MewsPageView> {
final _recognizers = <MewsLink, TapGestureRecognizer>{};
/// Images already asked for, so a rebuild shows the same one arriving
/// instead of asking again. A failure is forgotten, so trying again works.
final _images = <String, Future<Uint8List?>>{};
@override
void didUpdateWidget(MewsPageView oldWidget) {
super.didUpdateWidget(oldWidget);
@ -312,13 +323,7 @@ class _MewsPageViewState extends State<MewsPageView> {
),
ImageBlock(:final src, :final alt, :final allowed) => Padding(
padding: EdgeInsets.only(bottom: base),
child: allowed
? Image.network(
src,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
)
: _imageFallback(alt, base),
child: allowed ? _image(src, alt, base) : _imageFallback(alt, base),
),
FormBlock() => Padding(
padding: EdgeInsets.only(bottom: base),
@ -332,6 +337,40 @@ class _MewsPageViewState extends State<MewsPageView> {
};
}
/// An allowed image, from the platform on the web or from the protocol's
/// own bytes on a capsule. A blocked or unreachable image falls back to its
/// alt text, the way a missing image does.
Widget _image(String src, String alt, double base) {
final scheme = Uri.tryParse(src)?.scheme;
if (scheme != 'gemini' && scheme != 'spartan') {
return Image.network(
src,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
);
}
final loader = widget.fetchImageBytes;
if (loader == null) return _imageFallback(alt, base, unavailable: true);
return FutureBuilder<Uint8List?>(
future: _images.putIfAbsent(src, () {
final arriving = loader(src);
arriving.then((bytes) {
if (bytes == null) _images.remove(src);
});
return arriving;
}),
builder: (context, snapshot) => snapshot.connectionState != ConnectionState.done
? const SizedBox.shrink()
: snapshot.data == null
? _imageFallback(alt, base, unavailable: true)
: Image.memory(
snapshot.data!,
fit: BoxFit.scaleDown,
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
),
);
}
Widget _imageFallback(String alt, double base, {bool unavailable = false}) {
final palette = widget.palette;
final strings = StringsScope.of(context);

View file

@ -3,7 +3,15 @@ import 'package:flutter/rendering.dart' show ScrollDirection;
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../../application/controllers/browser_controller.dart'
show BrokenPage, LoadFailed;
show
BrokenPage,
CertificateChanged,
ClientCertificateRequired,
LoadFailed,
ProtocolFailure,
RedirectLoop,
UnsupportedPage;
import '../../database/database.dart' show ClientCertificate;
import '../../application/pages/mews_urls.dart';
import '../../mews/document.dart';
import '../../mews/parser.dart' show mewsPlainText;
@ -137,6 +145,24 @@ class _ReaderScreenState extends State<ReaderScreen> {
return switch (error) {
BrokenPage(:final source) => _errorPage(context, source),
LoadFailed() => _errorPage(context, null),
// A protocol status carries the site's own words for what went
// wrong.
ProtocolFailure(:final message) =>
_plainErrorPage(context, 'error-protocol-failed', detail: message),
// A certificate request is a choice, not a failure: the reader
// presents one of their identities, or none. A refused or invalid
// certificate is a failure the reader cannot fix by choosing again.
ClientCertificateRequired(:final status, :final meta) => status == 60
? _identityPickerPage(context, meta)
: _plainErrorPage(
context,
status == 61
? 'error-certificate-not-authorized'
: 'error-certificate-invalid',
detail: meta),
RedirectLoop() => _plainErrorPage(context, 'error-redirect-loop'),
UnsupportedPage() => _plainErrorPage(context, 'error-unsupported-type'),
CertificateChanged() => _certificateErrorPage(context),
null => _emptyPage(context),
};
});
@ -168,6 +194,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
onLinkTap: (url) => deps.browser.open(url),
onFormSubmit: (form, values) =>
deps.browser.submitForm(form.action, form.method, values),
fetchImageBytes: deps.browser.fetchImageBytes,
scrollController: _scroll,
);
});
@ -204,6 +231,111 @@ class _ReaderScreenState extends State<ReaderScreen> {
return const SizedBox.expand();
}
/// An error page that says [messageId], optionally the site's own words
/// under it, and offers to try again.
Widget _plainErrorPage(BuildContext context, String messageId, {String? detail}) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t(messageId), textAlign: TextAlign.center),
if (detail != null && detail.isNotEmpty) ...[
const SizedBox(height: 8),
Text(detail, textAlign: TextAlign.center),
],
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.reload(),
child: Text(strings.t('error-try-again')),
),
],
),
),
);
}
/// The choice a certificate request asks for: one of the reader's
/// identities, presented from now on to the site that asked.
Widget _identityPickerPage(BuildContext context, String meta) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
return FutureBuilder<List<ClientCertificate>>(
future: deps.db.allClientCertificates(),
builder: (context, snapshot) {
final held = snapshot.data ?? const <ClientCertificate>[];
return Center(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('certificates-choose'), textAlign: TextAlign.center),
if (meta.isNotEmpty) ...[
const SizedBox(height: 8),
Text(meta, textAlign: TextAlign.center),
],
const SizedBox(height: 16),
if (held.isEmpty)
FilledButton(
onPressed: () => deps.browser.open(MewsUrls.certificates),
child: Text(strings.t('certificates-add')),
)
else
for (final certificate in held)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FilledButton.tonal(
onPressed: () => deps.browser.chooseCertificate(certificate.id),
child: Text(certificate.name),
),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => deps.browser.reload(),
child: Text(strings.t('error-try-again')),
),
],
),
),
),
);
},
);
}
/// A changed certificate is the reader's choice to make or refuse, so the
/// error page carries both: trust what was presented, or try again.
Widget _certificateErrorPage(BuildContext context) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(strings.t('error-certificate-changed'), textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: () => deps.browser.trustPresentedCertificate(),
child: Text(strings.t('error-certificate-trust')),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => deps.browser.reload(),
child: Text(strings.t('error-try-again')),
),
],
),
),
);
}
Widget _errorPage(BuildContext context, String? brokenSource) {
final strings = AppScope.of(context).strings;
final deps = AppScope.of(context).deps;