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,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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue