Some checks failed
Security / Security check (push) Failing after 2s
The detail sheet always answers 'who maintains this?': one selectable line per maintainer from the store index's new maintainers list, or an honest 'not specified' when the index names nobody (StoreMaintainersSection, public for the widget tests). The install trust gate carries the same fact when present and stays terse otherwise. DE+EN; dialog-harness proof captured with the maintainer row. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
275 lines
10 KiB
Dart
275 lines
10 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.maintainers.isNotEmpty)
|
|
(l.installConfirmMaintainers, item.maintainers.join(', ')),
|
|
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(
|
|
// "blocked" means the hub WOULD refuse this install (policy
|
|
// demands signatures, no key material for this source) —
|
|
// offering an active Install button would contradict the
|
|
// statement right above it (doc-verifier finding).
|
|
onPressed: item.installVerification == 'blocked'
|
|
? null
|
|
: () => Navigator.pop(context, true),
|
|
child: Text(l.buttonInstall),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|