chain-studio/lib/widgets/chain_install_confirm.dart
flemming-it 2f076ccf29
Some checks failed
Security / Security check (push) Failing after 1s
feat(store,doctor): surface the hub's trust + exposure data (0.77.0)
The trust-gate dialog replaces its generic 'no per-entry status yet
(alpha)' note with the hub's classified verification statement —
pinned store key / trusted publishers (green), installs without
signature checking (amber), install would be refused (red), bridge
entry (neutral) — via one shared describeInstallVerification mapping.
Against a pre-0.23 hub the field is empty and the old honest wording
stays (pinned by test).

Information architecture: policy-off is a GLOBAL fact, so it appears
as ONE ChainInlineHelp notice above the store grid instead of a
warning pill on every card (card noise); only 'blocked' — a genuine
per-source anomaly — earns a card pill. Doctor's host services show
the hub-classified network reach per endpoint (local only / private
network / publicly reachable with a protect-it hint / reach unknown).

Verified end-to-end against the live dev hub (guide harness): the
wire field arrives, the store page shows exactly one policy notice
and quiet cards; trust-gate variants captured via the dialog
harness. Suite 123 green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-21 13:14:13 +02:00

267 lines
9.7 KiB
Dart

// Install confirmation with provenance + trust context. The
// usertest security persona flagged one-click installs with no
// visible trust signal as the platform's biggest supply-chain
// gap: every install path (store card, detail sheet, flow-list
// quick fix) now routes through this dialog, which shows what
// the hub actually knows before anything is downloaded — origin
// store, version, license, maturity, required services — plus
// the per-entry verification statement the hub computes with the
// SAME resolvers the install gate enforces (pinned key / trusted
// publishers / policy off / blocked / federated). Against a
// pre-0.23 hub the field is empty and the dialog keeps its
// generic when-verification-happens note instead of guessing.
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/install_verification.dart';
import '../l10n/app_localizations.dart';
import '../theme/tokens.dart';
class ChainInstallConfirmDialog extends StatelessWidget {
final StoreItem item;
const ChainInstallConfirmDialog({super.key, required this.item});
/// Confirm installing a known store entry. Returns true to
/// proceed.
static Future<bool> show(BuildContext context, StoreItem item) async {
final r = await showDialog<bool>(
context: context,
builder: (_) => ChainInstallConfirmDialog(item: item),
);
return r == true;
}
/// Confirm installing by bare capability name (the flow-list
/// quick-fix path). Looks the entry up in the store index so
/// the dialog shows the same provenance as the Store page;
/// falls back to a minimal entry when the index has no match
/// (the hub will reject unknown names on install anyway).
static Future<bool> showForCapability(
BuildContext context,
String capability,
) async {
StoreItem? match;
try {
final hits = await HubService.instance.searchStore(
query: capability,
limit: 10,
);
for (final h in hits) {
if (h.name == capability) {
match = h;
break;
}
}
} catch (_) {
// Unreachable store index — fall through to the minimal
// entry; the dialog still explains the trust model.
}
if (!context.mounted) return false;
return show(
context,
match ??
StoreItem(
name: capability,
taglineEn: '',
taglineDe: '',
descriptionEn: '',
descriptionDe: '',
category: '',
tags: const [],
requiresCapabilities: const [],
requiresServices: const [],
license: '',
repository: '',
bestVersion: '',
status: '',
installed: false,
featured: false,
iconUrl: '',
screenshotUrls: const [],
docsUrl: '',
kind: '',
provider: '',
sourceKind: '',
source: '',
canonicalCategory: '',
canonicalCategoryLabel: '',
),
);
}
String _statusLabel(AppLocalizations l) => switch (item.status) {
'published' => l.storeStatusPublished,
'alpha' => l.storeStatusAlpha,
'planned' => l.storeStatusPlanned,
_ => item.status,
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
final rows = <(String, String)>[
if (item.bestVersion.isNotEmpty)
(l.installConfirmVersion, 'v${item.bestVersion}'),
(
l.installConfirmSource,
item.source.isEmpty || item.source == 'bundled'
? l.installConfirmSourceBundled
: item.source,
),
if (item.license.isNotEmpty) (l.installConfirmLicense, item.license),
if (item.status.isNotEmpty) (l.installConfirmStatus, _statusLabel(l)),
if (item.requiresServices.isNotEmpty)
(l.installConfirmNeedsServices, item.requiresServices.join(', ')),
if (item.requiresCapabilities.isNotEmpty)
(
l.installConfirmNeedsCapabilities,
item.requiresCapabilities.join(', ')
),
];
return AlertDialog(
title: Text(l.installConfirmTitle(item.name)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ChainRadius.md),
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 460),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, value) in rows)
Padding(
padding: const EdgeInsets.only(bottom: ChainSpace.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(value, style: theme.textTheme.bodySmall),
),
],
),
),
const SizedBox(height: ChainSpace.sm),
Container(
padding: const EdgeInsets.all(ChainSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(ChainRadius.sm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.shield_outlined,
size: 14,
color: theme.colorScheme.primary,
),
const SizedBox(width: 6),
Text(
l.installConfirmTrustTitle,
style: theme.textTheme.labelMedium,
),
],
),
const SizedBox(height: ChainSpace.xs),
Text(
l.installConfirmTrustBody,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
const SizedBox(height: ChainSpace.xs),
Builder(
builder: (context) {
final info = describeInstallVerification(
item.installVerification,
l,
);
if (info == null) {
// Pre-0.23 hub: keep the generic note.
return Text(
l.installConfirmSignatureNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
);
}
final color = switch (info.tone) {
VerificationTone.good => ChainColors.success,
VerificationTone.warning => ChainColors.warning,
VerificationTone.danger => theme.colorScheme.error,
VerificationTone.neutral =>
theme.colorScheme.onSurfaceVariant,
};
final icon = switch (info.tone) {
VerificationTone.good => Icons.verified_outlined,
VerificationTone.warning =>
Icons.warning_amber_outlined,
VerificationTone.danger => Icons.gpp_bad_outlined,
VerificationTone.neutral => Icons.link_outlined,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
info.label,
style: theme.textTheme.bodySmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 2),
Text(
info.body,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
);
},
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(l.buttonInstall),
),
],
);
}
}