Some checks are pending
Security / Security check (push) Waiting to run
The module-store manager's 'Add a store' form gains an optional PEM public-key field; when set it pins that publisher key to the store (per-store signing trust). Localized DE/EN. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
441 lines
15 KiB
Dart
441 lines
15 KiB
Dart
// Module-store manager — list the configured module stores (+ the
|
|
// bundled seed), add a store by index URL, or remove one. The hub merges
|
|
// every store's modules into one searchable index, so adding a store
|
|
// (e.g. a domain app's published modules) makes them appear in the Store.
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:chain_client_sdk/chain_client_sdk.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/error_presentation.dart';
|
|
import '../data/hub.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/theme.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
class ChainStoresDialog extends StatefulWidget {
|
|
const ChainStoresDialog._();
|
|
|
|
static Future<void> show(BuildContext context) => showDialog(
|
|
context: context,
|
|
builder: (_) => const ChainStoresDialog._(),
|
|
);
|
|
|
|
@override
|
|
State<ChainStoresDialog> createState() => _ChainStoresDialogState();
|
|
}
|
|
|
|
class _ChainStoresDialogState extends State<ChainStoresDialog> {
|
|
late Future<List<StoreSource>> _future;
|
|
final _name = TextEditingController();
|
|
final _url = TextEditingController();
|
|
final _pubkey = TextEditingController();
|
|
bool _busy = false;
|
|
|
|
/// Reachability of each suggested store's index URL. `null` while the
|
|
/// probe is in flight, `true` once a response arrives that the hub
|
|
/// could parse (fail-open: network errors also count as `true`, so a
|
|
/// transient hiccup never hides a real store), `false` only on a
|
|
/// definitive 4xx/5xx — i.e. "the provider hasn't published it yet".
|
|
final Map<String, bool?> _reachable = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_reload();
|
|
for (final s in _kSuggestedStores) {
|
|
_reachable[s.url] = null;
|
|
_probe(s.url);
|
|
}
|
|
}
|
|
|
|
/// One-shot HEAD-ish probe of a suggested store's index. Fails open:
|
|
/// only a clean 4xx/5xx marks it unavailable; connection errors and
|
|
/// timeouts leave it available so the hub stays the real authority.
|
|
Future<void> _probe(String url) async {
|
|
if (!url.startsWith('http')) {
|
|
if (mounted) setState(() => _reachable[url] = true);
|
|
return;
|
|
}
|
|
bool available = true;
|
|
try {
|
|
final client = HttpClient()
|
|
..connectionTimeout = const Duration(seconds: 4);
|
|
final req = await client.getUrl(Uri.parse(url));
|
|
final resp = await req.close().timeout(const Duration(seconds: 5));
|
|
available = resp.statusCode >= 200 && resp.statusCode < 400;
|
|
await resp.drain<void>();
|
|
client.close(force: true);
|
|
} catch (_) {
|
|
// Offline / DNS / timeout — fail open. The add path will surface
|
|
// any real error if the operator clicks through.
|
|
available = true;
|
|
}
|
|
if (mounted) setState(() => _reachable[url] = available);
|
|
}
|
|
|
|
void _reload() =>
|
|
setState(() => _future = HubService.instance.listStores());
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_url.dispose();
|
|
_pubkey.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _add() => _addStore(
|
|
_name.text.trim(),
|
|
_url.text.trim(),
|
|
pinnedPubkeyPem: _pubkey.text.trim(),
|
|
fromFields: true,
|
|
);
|
|
|
|
/// Shared add path — used by both the manual name/URL form and the
|
|
/// one-click suggested-store buttons.
|
|
Future<void> _addStore(String name, String url,
|
|
{String pinnedPubkeyPem = '', bool fromFields = false}) async {
|
|
if (name.isEmpty || url.isEmpty) return;
|
|
setState(() => _busy = true);
|
|
try {
|
|
final r = await HubService.instance
|
|
.addStore(name: name, url: url, pinnedPubkeyPem: pinnedPubkeyPem);
|
|
if (!mounted) return;
|
|
if (fromFields) {
|
|
_name.clear();
|
|
_url.clear();
|
|
_pubkey.clear();
|
|
}
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(r.message)));
|
|
_reload();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
showFaiErrorSnack(context, 'store.add', e,
|
|
title: AppLocalizations.of(context)!.storesManagerAddFailed);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _remove(String name) async {
|
|
try {
|
|
final r = await HubService.instance.removeStore(name);
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(r.message)));
|
|
_reload();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
showFaiErrorSnack(context, 'store.remove', e,
|
|
title: AppLocalizations.of(context)!.storesManagerRemoveFailed);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.storesManagerTitle),
|
|
content: SizedBox(
|
|
width: 540,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
l.storesManagerIntro,
|
|
style: theme.textTheme.bodySmall
|
|
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
const SizedBox(height: ChainSpace.md),
|
|
Flexible(
|
|
child: FutureBuilder<List<StoreSource>>(
|
|
future: _future,
|
|
builder: (c, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
final stores = snap.data ?? const <StoreSource>[];
|
|
return ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
for (final s in stores)
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
s.removable
|
|
? Icons.cloud_outlined
|
|
: Icons.inventory_2_outlined,
|
|
size: 18,
|
|
),
|
|
title: Text(s.name),
|
|
subtitle: Text(
|
|
s.url.isEmpty
|
|
? l.storesManagerBundled(s.moduleCount)
|
|
: '${s.url}\n${l.storesManagerModuleCount(s.moduleCount)}',
|
|
),
|
|
isThreeLine: s.url.isNotEmpty,
|
|
trailing: s.removable
|
|
? IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: l.buttonRemove,
|
|
onPressed: () => _remove(s.name),
|
|
)
|
|
: const Icon(Icons.lock_outline, size: 16),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const Divider(),
|
|
// One-click curated stores — add or remove a known store
|
|
// (e.g. a domain app's published modules) without typing a
|
|
// URL. State follows the configured list above.
|
|
Text(l.storesManagerSuggested, style: theme.textTheme.titleSmall),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
FutureBuilder<List<StoreSource>>(
|
|
future: _future,
|
|
builder: (c, snap) {
|
|
final stores = snap.data ?? const <StoreSource>[];
|
|
final configuredNameByUrl = <String, String>{
|
|
for (final s in stores)
|
|
if (s.url.isNotEmpty) s.url: s.name,
|
|
};
|
|
return Column(
|
|
children: [
|
|
for (final sg in _kSuggestedStores)
|
|
_SuggestedStoreRow(
|
|
store: sg,
|
|
configuredName: configuredNameByUrl[sg.url],
|
|
available: _reachable[sg.url],
|
|
busy: _busy,
|
|
onAdd: () => _addStore(sg.name, sg.url),
|
|
onRemove: (name) => _remove(name),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: ChainSpace.md),
|
|
const Divider(),
|
|
Text(l.storesManagerAddSection, style: theme.textTheme.titleSmall),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller: _name,
|
|
decoration: InputDecoration(
|
|
labelText: l.storesManagerNameLabel,
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Expanded(
|
|
flex: 4,
|
|
child: TextField(
|
|
controller: _url,
|
|
decoration: InputDecoration(
|
|
labelText: l.storesManagerUrlLabel,
|
|
isDense: true,
|
|
),
|
|
onSubmitted: (_) => _add(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: ChainSpace.sm),
|
|
// Optional per-store pinned publisher key (PEM). When set,
|
|
// signed modules from this store verify against this key
|
|
// instead of the built-in vendor anchor.
|
|
TextField(
|
|
controller: _pubkey,
|
|
minLines: 1,
|
|
maxLines: 4,
|
|
style: ChainTheme.mono(size: 11),
|
|
decoration: InputDecoration(
|
|
labelText: l.storesManagerPinnedKeyLabel,
|
|
helperText: l.storesManagerPinnedKeyHint,
|
|
helperMaxLines: 3,
|
|
isDense: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: Text(l.buttonClose),
|
|
),
|
|
FilledButton(
|
|
onPressed: _busy ? null : _add,
|
|
child: _busy
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(l.storesManagerAddAction),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A curated, one-click store the operator can add or remove without
|
|
/// typing a URL. The hub validates the index on add, so the entry
|
|
/// only succeeds once its index is actually published at [url].
|
|
class _SuggestedStore {
|
|
final String name;
|
|
final String url;
|
|
final String Function(AppLocalizations) describe;
|
|
const _SuggestedStore({
|
|
required this.name,
|
|
required this.url,
|
|
required this.describe,
|
|
});
|
|
}
|
|
|
|
/// Known stores offered as quick-adds. Keep this short and curated —
|
|
/// it is the in-product shelf of trusted domain stores. The URL is the
|
|
/// canonical publish location each store owner commits to (see the
|
|
/// cross-agent handoff log for Recl∆Im's).
|
|
final List<_SuggestedStore> _kSuggestedStores = <_SuggestedStore>[
|
|
_SuggestedStore(
|
|
name: 'Recl∆Im',
|
|
// Raw store-index file in the public chain-modules/reclaim repo —
|
|
// stable (no release-tag churn), updated by a plain git push as
|
|
// reclaim adds modules. The hub fetches it via StoreIndex::from_url.
|
|
url: 'https://git.flemming.ai/chain-modules/reclaim/raw/branch/main/store.yaml',
|
|
describe: (l) => l.storesSuggestedReclaimDesc,
|
|
),
|
|
];
|
|
|
|
/// One row in the suggested-stores shelf: icon, name, description, and
|
|
/// a single Add/Remove button that flips based on whether the store is
|
|
/// already configured.
|
|
class _SuggestedStoreRow extends StatelessWidget {
|
|
final _SuggestedStore store;
|
|
|
|
/// The configured store's name when this suggestion is already
|
|
/// added (matched by URL), else null.
|
|
final String? configuredName;
|
|
|
|
/// Index reachability: null = still probing, true = reachable,
|
|
/// false = a definitive 4xx/5xx (not published yet).
|
|
final bool? available;
|
|
final bool busy;
|
|
final VoidCallback onAdd;
|
|
final ValueChanged<String> onRemove;
|
|
|
|
const _SuggestedStoreRow({
|
|
required this.store,
|
|
required this.configuredName,
|
|
required this.available,
|
|
required this.busy,
|
|
required this.onAdd,
|
|
required this.onRemove,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final added = configuredName != null;
|
|
// Not-yet-published: a definitive bad response and not already
|
|
// configured. Show a muted "not available yet" chip instead of an
|
|
// Add button that would only fail on click.
|
|
final notPublished = !added && available == false;
|
|
final probing = !added && available == null;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.storefront_outlined,
|
|
size: 18,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
store.name,
|
|
style: theme.textTheme.bodyMedium
|
|
?.copyWith(fontWeight: FontWeight.w600),
|
|
),
|
|
Text(
|
|
store.describe(l),
|
|
style: theme.textTheme.bodySmall
|
|
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: ChainSpace.sm),
|
|
if (added)
|
|
OutlinedButton.icon(
|
|
icon: const Icon(Icons.check, size: 14),
|
|
label: Text(l.buttonRemove),
|
|
onPressed: busy ? null : () => onRemove(configuredName!),
|
|
style: OutlinedButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
foregroundColor: ChainColors.success,
|
|
),
|
|
)
|
|
else if (probing)
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
else if (notPublished)
|
|
Tooltip(
|
|
message: l.storesManagerNotAvailableHint,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.schedule_outlined,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
l.storesManagerNotAvailable,
|
|
style: theme.textTheme.bodySmall
|
|
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
FilledButton.tonalIcon(
|
|
icon: const Icon(Icons.add, size: 14),
|
|
label: Text(l.storesManagerAddShort),
|
|
onPressed: busy ? null : onAdd,
|
|
style: FilledButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|