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,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;