Some checks failed
Security / Security check (push) Failing after 1s
The doc-verifier pass over the new trust/exposure surfaces came back PASS with five improvements, all applied: - the store policy notice gains a 'Learn more' into the security doc (the notice named security.require_signatures but not where it lives) - 'blocked' disables the trust gate's install button — an active button contradicted the 'install would be refused' statement right above it (guard test added) - the unknown-exposure tooltip now says what the operator can do (check where the name resolves) - dead l10n key verifPillUnverified removed (unverified is the page-level notice, never a card pill) - stale header comment in install_verification.dart corrected Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
273 lines
10 KiB
Dart
273 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.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),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|