feat(studio): show a source module's data terms before the install button
Some checks failed
Security / Security check (push) Failing after 2s
Some checks failed
Security / Security check (push) Failing after 2s
The store detail sheet now carries a data-source block for source.* modules: publisher, upstream url, the terms in plain words, and any attribution the operator has to carry with the output. It sits above maintainers and above the install button, because it is a decision input rather than a footnote. The values are selectable: compliance notes get written by copying, not retyping. A note names whose terms these are, so nobody reads them as the module's own licence. Four guards, including that an empty attribution renders no empty row. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
fb809a4cbd
commit
35a79d0bb6
8 changed files with 307 additions and 44 deletions
|
|
@ -983,6 +983,14 @@ class HubService {
|
|||
canonicalCategoryLabel: e.canonicalCategoryLabel,
|
||||
installVerification: e.installVerification,
|
||||
maintainers: e.maintainers,
|
||||
dataSource: e.hasDataSource()
|
||||
? DataProvenance(
|
||||
name: e.dataSource.name,
|
||||
url: e.dataSource.url,
|
||||
license: e.dataSource.license,
|
||||
attribution: e.dataSource.attribution,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
|
@ -2454,6 +2462,11 @@ class StoreItem {
|
|||
/// any — the detail sheet renders an honest "not specified".
|
||||
final List<String> maintainers;
|
||||
|
||||
/// Where a `source.*` module's data comes from, and under what
|
||||
/// terms. Null for every other module: the module's own licence
|
||||
/// covers its code, this covers material it reaches at runtime.
|
||||
final DataProvenance? dataSource;
|
||||
|
||||
/// How an install of this entry would be verified under the
|
||||
/// hub's CURRENT policy — computed hub-side with the same
|
||||
/// resolvers the install gate enforces, so this can never
|
||||
|
|
@ -2496,5 +2509,30 @@ class StoreItem {
|
|||
this.canonicalCategoryLabel = '',
|
||||
this.installVerification = '',
|
||||
this.maintainers = const [],
|
||||
this.dataSource,
|
||||
});
|
||||
}
|
||||
|
||||
/// Provenance of the material a source module fetches.
|
||||
class DataProvenance {
|
||||
/// Publisher, as a person would name it.
|
||||
final String name;
|
||||
|
||||
/// Canonical URL of the upstream source.
|
||||
final String url;
|
||||
|
||||
/// Terms in plain words, not SPDX: statutes carry no software
|
||||
/// licence at all.
|
||||
final String license;
|
||||
|
||||
/// Attribution the operator must carry with the output; empty when
|
||||
/// the upstream requires none.
|
||||
final String attribution;
|
||||
|
||||
const DataProvenance({
|
||||
required this.name,
|
||||
required this.url,
|
||||
required this.license,
|
||||
required this.attribution,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1670,6 +1670,10 @@
|
|||
"installConfirmTrustTitle": "Vertrauen & Sicherheit",
|
||||
"installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.",
|
||||
"storeSectionMaintainers": "Maintainer",
|
||||
"storeSectionDataSource": "Datenquelle",
|
||||
"storeDataSourceLicense": "Bedingungen",
|
||||
"storeDataSourceAttribution": "Namensnennung erforderlich",
|
||||
"storeDataSourceNote": "Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.",
|
||||
"storeMaintainersNone": "nicht angegeben",
|
||||
"installConfirmMaintainers": "Maintainer",
|
||||
"storePolicyUnverifiedNotice": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.",
|
||||
|
|
|
|||
|
|
@ -1711,6 +1711,10 @@
|
|||
"installConfirmTrustTitle": "Trust & security",
|
||||
"installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.",
|
||||
"storeSectionMaintainers": "Maintainers",
|
||||
"storeSectionDataSource": "Data source",
|
||||
"storeDataSourceLicense": "Terms",
|
||||
"storeDataSourceAttribution": "Attribution required",
|
||||
"storeDataSourceNote": "These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.",
|
||||
"storeMaintainersNone": "not specified",
|
||||
"installConfirmMaintainers": "Maintainers",
|
||||
"storePolicyUnverifiedNotice": "Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.",
|
||||
|
|
|
|||
|
|
@ -5174,6 +5174,30 @@ abstract class AppLocalizations {
|
|||
/// **'Maintainers'**
|
||||
String get storeSectionMaintainers;
|
||||
|
||||
/// No description provided for @storeSectionDataSource.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Data source'**
|
||||
String get storeSectionDataSource;
|
||||
|
||||
/// No description provided for @storeDataSourceLicense.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Terms'**
|
||||
String get storeDataSourceLicense;
|
||||
|
||||
/// No description provided for @storeDataSourceAttribution.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Attribution required'**
|
||||
String get storeDataSourceAttribution;
|
||||
|
||||
/// No description provided for @storeDataSourceNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.'**
|
||||
String get storeDataSourceNote;
|
||||
|
||||
/// No description provided for @storeMaintainersNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -3056,6 +3056,19 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get storeSectionMaintainers => 'Maintainer';
|
||||
|
||||
@override
|
||||
String get storeSectionDataSource => 'Datenquelle';
|
||||
|
||||
@override
|
||||
String get storeDataSourceLicense => 'Bedingungen';
|
||||
|
||||
@override
|
||||
String get storeDataSourceAttribution => 'Namensnennung erforderlich';
|
||||
|
||||
@override
|
||||
String get storeDataSourceNote =>
|
||||
'Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.';
|
||||
|
||||
@override
|
||||
String get storeMaintainersNone => 'nicht angegeben';
|
||||
|
||||
|
|
|
|||
|
|
@ -3052,6 +3052,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get storeSectionMaintainers => 'Maintainers';
|
||||
|
||||
@override
|
||||
String get storeSectionDataSource => 'Data source';
|
||||
|
||||
@override
|
||||
String get storeDataSourceLicense => 'Terms';
|
||||
|
||||
@override
|
||||
String get storeDataSourceAttribution => 'Attribution required';
|
||||
|
||||
@override
|
||||
String get storeDataSourceNote =>
|
||||
'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.';
|
||||
|
||||
@override
|
||||
String get storeMaintainersNone => 'not specified';
|
||||
|
||||
|
|
|
|||
|
|
@ -253,8 +253,9 @@ class _StorePageState extends State<StorePage> {
|
|||
icon: const Icon(Icons.add_business_outlined, size: 16),
|
||||
label: Text(l.storesManagerButton),
|
||||
onPressed: () => ChainStoresDialog.show(context),
|
||||
style:
|
||||
OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
style: OutlinedButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
|
|
@ -365,8 +366,7 @@ class _StorePageState extends State<StorePage> {
|
|||
// Modules vs Studio plugins/themes — a theme
|
||||
// extends the GUI, a module runs in a flow.
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(bottom: ChainSpace.md),
|
||||
padding: const EdgeInsets.only(bottom: ChainSpace.md),
|
||||
child: ChainSegments<bool>(
|
||||
items: [
|
||||
ChainSegmentItem(
|
||||
|
|
@ -381,8 +381,7 @@ class _StorePageState extends State<StorePage> {
|
|||
),
|
||||
],
|
||||
value: _showStudio,
|
||||
onChanged: (v) =>
|
||||
setState(() => _showStudio = v),
|
||||
onChanged: (v) => setState(() => _showStudio = v),
|
||||
),
|
||||
),
|
||||
// ONE page-level notice when the hub says
|
||||
|
|
@ -1695,18 +1694,15 @@ class _StoreGrid extends StatelessWidget {
|
|||
children: [
|
||||
Text(
|
||||
_canonicalCatLabel(context, slug, labels[slug] ?? ''),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: ChainSpace.sm),
|
||||
Text(
|
||||
'${groups[slug]!.length}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
|
|
@ -1932,18 +1928,14 @@ class _FeaturedTile extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(width: ChainSpace.xs),
|
||||
if (item.status.isNotEmpty)
|
||||
_statusPill(context, item.status),
|
||||
if (item.status.isNotEmpty) _statusPill(context, item.status),
|
||||
// License on the card, not only in the detail
|
||||
// sheet — buyers scan the grid for exactly this
|
||||
// (usertest finding: no license/cost signal per
|
||||
// module before clicking).
|
||||
if (item.license.isNotEmpty) ...[
|
||||
const SizedBox(width: ChainSpace.xs),
|
||||
ChainPill(
|
||||
label: item.license,
|
||||
tone: ChainPillTone.neutral,
|
||||
),
|
||||
ChainPill(label: item.license, tone: ChainPillTone.neutral),
|
||||
],
|
||||
const Spacer(),
|
||||
if (item.installed)
|
||||
|
|
@ -2230,7 +2222,9 @@ class _StoreDetailSheet extends StatefulWidget {
|
|||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
elevation: 8,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.md)),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(ChainRadius.md),
|
||||
),
|
||||
),
|
||||
builder: (_) => _StoreDetailSheet(item: item, locale: locale),
|
||||
);
|
||||
|
|
@ -2314,8 +2308,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
Future<void> _install() async {
|
||||
// Same trust gate as the store card — the detail sheet's
|
||||
// install button must not be a quieter bypass.
|
||||
final confirmed =
|
||||
await ChainInstallConfirmDialog.show(context, widget.item);
|
||||
final confirmed = await ChainInstallConfirmDialog.show(
|
||||
context,
|
||||
widget.item,
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
setState(() {
|
||||
_busy = true;
|
||||
|
|
@ -2686,6 +2682,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
_DocsPanel(text: _docsResult!.text),
|
||||
const SizedBox(height: ChainSpace.lg),
|
||||
],
|
||||
if (item.dataSource != null)
|
||||
StoreDataSourceSection(source: item.dataSource!),
|
||||
StoreMaintainersSection(maintainers: item.maintainers),
|
||||
if (item.repository.isNotEmpty) ...[
|
||||
_SectionHeader(l.storeSectionSource),
|
||||
|
|
@ -2781,7 +2779,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||
borderRadius: BorderRadius.circular(
|
||||
ChainRadius.sm,
|
||||
),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
|
|
@ -2969,7 +2969,11 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (done) ...[
|
||||
Icon(Icons.check_circle_outline, size: 32, color: ChainColors.success),
|
||||
Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 32,
|
||||
color: ChainColors.success,
|
||||
),
|
||||
const SizedBox(height: ChainSpace.md),
|
||||
Text(
|
||||
l.storeInstalledToast(_result!.name, _result!.version),
|
||||
|
|
@ -3947,6 +3951,84 @@ class StoreMaintainersSection extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Provenance block for `source.*` modules: who publishes the data
|
||||
/// the module fetches, and under what terms. Sits above the install
|
||||
/// button because it is a decision input, not a footnote — the terms
|
||||
/// of the material are separate from the module's own licence, and
|
||||
/// an operator taking on an attribution duty should see it first.
|
||||
/// Public so the widget test pumps it directly.
|
||||
class StoreDataSourceSection extends StatelessWidget {
|
||||
final DataProvenance source;
|
||||
|
||||
const StoreDataSourceSection({super.key, required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final muted = theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionHeader(l.storeSectionDataSource),
|
||||
const SizedBox(height: ChainSpace.sm),
|
||||
SelectableText(source.name, style: theme.textTheme.bodySmall),
|
||||
if (source.url.isNotEmpty)
|
||||
SelectableText(
|
||||
source.url,
|
||||
style: ChainTheme.mono(size: 11, color: theme.colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: ChainSpace.sm),
|
||||
_ProvenanceRow(label: l.storeDataSourceLicense, value: source.license),
|
||||
if (source.attribution.isNotEmpty)
|
||||
_ProvenanceRow(
|
||||
label: l.storeDataSourceAttribution,
|
||||
value: source.attribution,
|
||||
),
|
||||
const SizedBox(height: ChainSpace.sm),
|
||||
Text(l.storeDataSourceNote, style: muted),
|
||||
const SizedBox(height: ChainSpace.lg),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Label and value on one line, value selectable so an attribution
|
||||
/// string can be copied straight into a compliance note.
|
||||
class _ProvenanceRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _ProvenanceRow({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(value, style: theme.textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Signature-verification warning pill. Same philosophy as the
|
||||
/// provenance pill: the GOOD path (signature checked at install)
|
||||
/// stays quiet, and so does the policy-off case — that one is a
|
||||
|
|
|
|||
85
test/store_data_source_test.dart
Normal file
85
test/store_data_source_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Guards for the data-provenance block in the store detail sheet.
|
||||
//
|
||||
// The rule it protects (docs/architecture/store-format.md): the terms
|
||||
// of the material a source module fetches are separate from the
|
||||
// module's own licence, and an operator must see them before the
|
||||
// install button, not in a README afterwards.
|
||||
|
||||
import 'package:chain_studio/data/hub.dart';
|
||||
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||
import 'package:chain_studio/pages/store.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _host(Widget child) => MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: const Locale('de'),
|
||||
home: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
);
|
||||
|
||||
void main() {
|
||||
const bund = DataProvenance(
|
||||
name: 'gesetze-im-internet.de (BMJ / juris GmbH)',
|
||||
url: 'https://www.gesetze-im-internet.de/',
|
||||
license: 'Amtliches Werk (§ 5 UrhG), gemeinfrei',
|
||||
attribution: 'Quelle: gesetze-im-internet.de (Bundesministerium der Justiz)',
|
||||
);
|
||||
|
||||
testWidgets('publisher, terms and attribution are all shown',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(bund.name), findsOneWidget);
|
||||
expect(find.text(bund.url), findsOneWidget);
|
||||
expect(find.text(bund.license), findsOneWidget);
|
||||
expect(find.text(bund.attribution), findsOneWidget,
|
||||
reason: 'an attribution duty must be visible before installing');
|
||||
});
|
||||
|
||||
testWidgets('the attribution row is omitted when none is required',
|
||||
(tester) async {
|
||||
const noAttribution = DataProvenance(
|
||||
name: 'Vom Betreiber gepflegter Metadaten-Katalog',
|
||||
url: '',
|
||||
license: 'Katalog des Betreibers',
|
||||
attribution: '',
|
||||
);
|
||||
await tester
|
||||
.pumpWidget(_host(const StoreDataSourceSection(source: noAttribution)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text(noAttribution.name), findsOneWidget);
|
||||
final l = AppLocalizations.of(
|
||||
tester.element(find.byType(StoreDataSourceSection)),
|
||||
)!;
|
||||
expect(find.text(l.storeDataSourceAttribution), findsNothing,
|
||||
reason: 'an empty attribution must not render an empty row');
|
||||
});
|
||||
|
||||
testWidgets('the terms are selectable so they can be copied',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Compliance notes get written by copying, not retyping.
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is SelectableText && w.data == bund.attribution,
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('the note says whose terms these are', (tester) async {
|
||||
await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final l = AppLocalizations.of(
|
||||
tester.element(find.byType(StoreDataSourceSection)),
|
||||
)!;
|
||||
// Without this line an operator reads the terms as the module's.
|
||||
expect(find.text(l.storeDataSourceNote), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue