chain-studio/lib/widgets/chain_stores_dialog.dart
flemming-it 7625509237
Some checks are pending
Security / Security check (push) Waiting to run
feat(studio): optional pinned-key field in the store-add form
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>
2026-06-19 02:54:50 +02:00

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,
),
),
],
),
);
}
}