From 1f1e050b420d042d0619c743f38fab2d0aa75aa5 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 23:18:41 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20guided=20setup=20=E2=80=94=20clicka?= =?UTF-8?q?ble=20next=20steps,=20signature=20relaxation,=20fresh-install?= =?UTF-8?q?=20auto-open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-apply the wizard now renders real Studio actions instead of CLI text: a start-hub button that polls until the daemon answers, per-module install buttons (capability-name install via the hub's store index) with done/progress states, and an open-the-starter-flow button that navigates to the Flows page. Regulated plans explain in plain language that modules come from a signed source; the preview offers 'allow installing from the public store' as one deliberate, reversible switch that re-assembles the plan (allow_unsigned_modules). Fresh installs (no config, no setup-plan.yaml) auto-open the wizard once per run — the wizard IS the onboarding — and it steps back once a setup exists. The welcome CTA is framed honestly ('get started in 3 questions'), and after the wizard closes the onboarding checklist remounts, re-probes, and says what the assistant already covered (profile line from setup-plan.yaml) instead of acting as a second, disconnected onboarding surface. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 24 +- lib/l10n/app_en.arb | 22 +- lib/l10n/app_localizations.dart | 84 ++++++- lib/l10n/app_localizations_de.dart | 57 ++++- lib/l10n/app_localizations_en.dart | 53 ++++- lib/pages/welcome.dart | 83 ++++++- lib/widgets/guided_setup_dialog.dart | 331 ++++++++++++++++++++++++--- test/guided_setup_test.dart | 101 ++++++++ 8 files changed, 704 insertions(+), 51 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cefdd8d..75b8838 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -4,7 +4,7 @@ "errModuleDownloadHint": "Die Bundle-URL des Moduls ist nicht erreichbar (Statuscode unten). Der Hub läuft — URL/Host korrigieren oder anderen Store wählen.", "storeFromSource": "aus {store}", "guidedSetupTitle": "Setup-Assistent", - "guidedSetupIntro": "Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.", + "guidedSetupIntro": "Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.", "guidedSetupScenario": "Szenario", "guidedSetupIntent": "Aufgabe", "guidedSetupTarget": "Wo läuft es?", @@ -1747,7 +1747,23 @@ "setupApplied": "Fertig — Ch∆In ist eingerichtet.", "setupNextTitle": "Nächste Schritte", "setupNextHubStart": "Hub starten", - "setupNextInstall": "Die Module installieren", - "setupNextRunFlow": "Den Beispiel-Flow ausführen", - "setupStartCta": "In 3 Fragen loslegen" + "setupHubRunning": "Der Hub läuft.", + "setupStartHubFirst": "Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.", + "setupActionInstall": "{module} installieren", + "@setupActionInstall": {"placeholders": {"module": {"type": "String"}}}, + "setupActionInstalled": "{module} ist installiert.", + "@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}}, + "setupActionOpenFlow": "Beispiel-Flow „{flow}“ öffnen", + "@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}}, + "setupModulesOfflineHint": "Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.", + "setupModulesSignedHint": "Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.", + "setupSigPublicStoreNotice": "Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.", + "setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben", + "setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.", + "setupStartCta": "In 3 Fragen loslegen", + "setupProfileDev": "unkomplizierter Arbeitsplatz", + "setupProfileEnterprise": "abgesicherter Team-Betrieb", + "setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb", + "welcomeChecklistSetupDone": "Die Grundeinrichtung ({profile}) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.", + "@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cfb73e2..6314d57 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1786,7 +1786,23 @@ "setupApplied": "Done — Ch∆In is set up.", "setupNextTitle": "Next steps", "setupNextHubStart": "Start the hub", - "setupNextInstall": "Install the modules", - "setupNextRunFlow": "Run the example flow", - "setupStartCta": "Get started in 3 questions" + "setupHubRunning": "The hub is running.", + "setupStartHubFirst": "Start the hub first — then the modules can be installed right here.", + "setupActionInstall": "Install {module}", + "@setupActionInstall": {"placeholders": {"module": {"type": "String"}}}, + "setupActionInstalled": "{module} is installed.", + "@setupActionInstalled": {"placeholders": {"module": {"type": "String"}}}, + "setupActionOpenFlow": "Open the “{flow}” example flow", + "@setupActionOpenFlow": {"placeholders": {"flow": {"type": "String"}}}, + "setupModulesOfflineHint": "The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.", + "setupModulesSignedHint": "The modules come from your own, signed source — not from the public store.", + "setupSigPublicStoreNotice": "This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.", + "setupAllowUnsigned": "Allow installing from the public store", + "setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.", + "setupStartCta": "Get started in 3 questions", + "setupProfileDev": "straightforward workstation", + "setupProfileEnterprise": "hardened team operation", + "setupProfileAirgapped": "fully local, regulated operation", + "welcomeChecklistSetupDone": "The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.", + "@welcomeChecklistSetupDone": {"placeholders": {"profile": {"type": "String"}}} } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e0be5a4..5b7ec8e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5329,23 +5329,95 @@ abstract class AppLocalizations { /// **'Start the hub'** String get setupNextHubStart; - /// No description provided for @setupNextInstall. + /// No description provided for @setupHubRunning. /// /// In en, this message translates to: - /// **'Install the modules'** - String get setupNextInstall; + /// **'The hub is running.'** + String get setupHubRunning; - /// No description provided for @setupNextRunFlow. + /// No description provided for @setupStartHubFirst. /// /// In en, this message translates to: - /// **'Run the example flow'** - String get setupNextRunFlow; + /// **'Start the hub first — then the modules can be installed right here.'** + String get setupStartHubFirst; + + /// No description provided for @setupActionInstall. + /// + /// In en, this message translates to: + /// **'Install {module}'** + String setupActionInstall(String module); + + /// No description provided for @setupActionInstalled. + /// + /// In en, this message translates to: + /// **'{module} is installed.'** + String setupActionInstalled(String module); + + /// No description provided for @setupActionOpenFlow. + /// + /// In en, this message translates to: + /// **'Open the “{flow}” example flow'** + String setupActionOpenFlow(String flow); + + /// No description provided for @setupModulesOfflineHint. + /// + /// In en, this message translates to: + /// **'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'** + String get setupModulesOfflineHint; + + /// No description provided for @setupModulesSignedHint. + /// + /// In en, this message translates to: + /// **'The modules come from your own, signed source — not from the public store.'** + String get setupModulesSignedHint; + + /// No description provided for @setupSigPublicStoreNotice. + /// + /// In en, this message translates to: + /// **'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'** + String get setupSigPublicStoreNotice; + + /// No description provided for @setupAllowUnsigned. + /// + /// In en, this message translates to: + /// **'Allow installing from the public store'** + String get setupAllowUnsigned; + + /// No description provided for @setupAllowUnsignedSub. + /// + /// In en, this message translates to: + /// **'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'** + String get setupAllowUnsignedSub; /// No description provided for @setupStartCta. /// /// In en, this message translates to: /// **'Get started in 3 questions'** String get setupStartCta; + + /// No description provided for @setupProfileDev. + /// + /// In en, this message translates to: + /// **'straightforward workstation'** + String get setupProfileDev; + + /// No description provided for @setupProfileEnterprise. + /// + /// In en, this message translates to: + /// **'hardened team operation'** + String get setupProfileEnterprise; + + /// No description provided for @setupProfileAirgapped. + /// + /// In en, this message translates to: + /// **'fully local, regulated operation'** + String get setupProfileAirgapped; + + /// No description provided for @welcomeChecklistSetupDone. + /// + /// In en, this message translates to: + /// **'The setup assistant already took care of the basics ({profile}) — this list shows what has actually happened on your hub.'** + String welcomeChecklistSetupDone(String profile); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e78a64b..2c3bc1b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -25,7 +25,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get guidedSetupIntro => - 'Beantworte drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Start-Flow zusammen.'; + 'Beantworten Sie drei Fragen — Ch∆In stellt passende Konfiguration, Modul-Set und Beispiel-Flow zusammen.'; @override String get guidedSetupScenario => 'Szenario'; @@ -3138,11 +3138,62 @@ class AppLocalizationsDe extends AppLocalizations { String get setupNextHubStart => 'Hub starten'; @override - String get setupNextInstall => 'Die Module installieren'; + String get setupHubRunning => 'Der Hub läuft.'; @override - String get setupNextRunFlow => 'Den Beispiel-Flow ausführen'; + String get setupStartHubFirst => + 'Starten Sie zuerst den Hub — dann lassen sich die Module hier installieren.'; + + @override + String setupActionInstall(String module) { + return '$module installieren'; + } + + @override + String setupActionInstalled(String module) { + return '$module ist installiert.'; + } + + @override + String setupActionOpenFlow(String flow) { + return 'Beispiel-Flow „$flow“ öffnen'; + } + + @override + String get setupModulesOfflineHint => + 'Die Module kommen ohne Internet als Offline-Paket auf den Server — die Anleitung dazu steht in der Dokumentation unter „Betrieb ohne Internet“.'; + + @override + String get setupModulesSignedHint => + 'Die Module kommen aus einer eigenen, signierten Quelle — nicht aus dem öffentlichen Store.'; + + @override + String get setupSigPublicStoreNotice => + 'Diese Einrichtung lässt nur signierte Module zu. Der öffentliche Store liefert zurzeit unsignierte Pakete — die vorbereiteten Module kommen daher aus einer eigenen, signierten Quelle.'; + + @override + String get setupAllowUnsigned => + 'Installation aus dem öffentlichen Store erlauben'; + + @override + String get setupAllowUnsignedSub => + 'Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.'; @override String get setupStartCta => 'In 3 Fragen loslegen'; + + @override + String get setupProfileDev => 'unkomplizierter Arbeitsplatz'; + + @override + String get setupProfileEnterprise => 'abgesicherter Team-Betrieb'; + + @override + String get setupProfileAirgapped => + 'vollständig lokaler, regulierter Betrieb'; + + @override + String welcomeChecklistSetupDone(String profile) { + return 'Die Grundeinrichtung ($profile) hat der Einrichtungs-Assistent bereits erledigt — hier sehen Sie, was auf dem Hub schon passiert ist.'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6a7d777..4342406 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3136,11 +3136,60 @@ class AppLocalizationsEn extends AppLocalizations { String get setupNextHubStart => 'Start the hub'; @override - String get setupNextInstall => 'Install the modules'; + String get setupHubRunning => 'The hub is running.'; @override - String get setupNextRunFlow => 'Run the example flow'; + String get setupStartHubFirst => + 'Start the hub first — then the modules can be installed right here.'; + + @override + String setupActionInstall(String module) { + return 'Install $module'; + } + + @override + String setupActionInstalled(String module) { + return '$module is installed.'; + } + + @override + String setupActionOpenFlow(String flow) { + return 'Open the “$flow” example flow'; + } + + @override + String get setupModulesOfflineHint => + 'The modules arrive as an offline bundle, without internet — the documentation explains this under “running without internet”.'; + + @override + String get setupModulesSignedHint => + 'The modules come from your own, signed source — not from the public store.'; + + @override + String get setupSigPublicStoreNotice => + 'This setup only accepts signed modules. The public store currently ships unsigned bundles — the prepared modules therefore come from your own, signed source.'; + + @override + String get setupAllowUnsigned => 'Allow installing from the public store'; + + @override + String get setupAllowUnsignedSub => + 'Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.'; @override String get setupStartCta => 'Get started in 3 questions'; + + @override + String get setupProfileDev => 'straightforward workstation'; + + @override + String get setupProfileEnterprise => 'hardened team operation'; + + @override + String get setupProfileAirgapped => 'fully local, regulated operation'; + + @override + String welcomeChecklistSetupDone(String profile) { + return 'The setup assistant already took care of the basics ($profile) — this list shows what has actually happened on your hub.'; + } } diff --git a/lib/pages/welcome.dart b/lib/pages/welcome.dart index 144b52f..985f665 100644 --- a/lib/pages/welcome.dart +++ b/lib/pages/welcome.dart @@ -22,9 +22,44 @@ import '../theme/tokens.dart'; import '../widgets/guided_setup_dialog.dart'; import '../widgets/widgets.dart'; -class WelcomePage extends StatelessWidget { +class WelcomePage extends StatefulWidget { const WelcomePage({super.key}); + @override + State createState() => _WelcomePageState(); +} + +class _WelcomePageState extends State { + /// Bumped when the guided-setup dialog closes so the onboarding + /// checklist remounts and re-probes — the assistant may have just + /// installed the modules the checklist looks for. + int _checklistEpoch = 0; + + /// One auto-open per app run: a fresh install that dismisses the + /// wizard should not have it spring back on every rebuild. + static bool _autoOpenedThisRun = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoOpen()); + } + + /// On a fresh hub (no config, no recorded setup plan) the wizard + /// IS the onboarding — open it rather than hoping the operator + /// finds the button. Once a setup exists it steps back for good. + Future _maybeAutoOpen() async { + if (_autoOpenedThisRun || !mounted) return; + if (!GuidedSetupDialog.isFreshInstall()) return; + _autoOpenedThisRun = true; + await _openSetup(); + } + + Future _openSetup() async { + await GuidedSetupDialog.show(context); + if (mounted) setState(() => _checklistEpoch++); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -65,11 +100,12 @@ class WelcomePage extends StatelessWidget { alignment: Alignment.centerLeft, // High-emphasis: the guided setup is the fastest path // to a working first run, so lead with a filled button - // rather than a low-key outlined one. + // rather than a low-key outlined one. Honest framing: + // "get started in 3 questions", not tool-speak. child: FilledButton.icon( - onPressed: () => GuidedSetupDialog.show(context), + onPressed: _openSetup, icon: const Icon(Icons.auto_fix_high), - label: Text(AppLocalizations.of(context)!.guidedSetupTitle), + label: Text(l.setupStartCta), ), ), const SizedBox(height: ChainSpace.xxl), @@ -81,7 +117,7 @@ class WelcomePage extends StatelessWidget { const _HubDownHero(), const SizedBox(height: ChainSpace.xxl), ] else - const _OnboardingChecklist(), + _OnboardingChecklist(key: ValueKey(_checklistEpoch)), const _PillarRow(), const SizedBox(height: ChainSpace.xxl), const _SectionLabel(textKey: _SectionLabelKey.trust), @@ -515,7 +551,7 @@ const String _kChecklistDismissedKey = 'welcome.checklist.dismissed'; /// parallel; failures stay false and the checklist still /// renders (with the "Refresh" button for retry). class _OnboardingChecklist extends StatefulWidget { - const _OnboardingChecklist(); + const _OnboardingChecklist({super.key}); @override State<_OnboardingChecklist> createState() => _OnboardingChecklistState(); @@ -530,6 +566,12 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { bool _moduleOk = false; bool _flowOk = false; + /// Profile the guided setup applied (from `setup-plan.yaml`), or + /// null when the assistant never ran. Connects the two onboarding + /// surfaces: the checklist says what the assistant already covered + /// instead of ignoring it. + String? _setupProfile; + @override void initState() { super.initState(); @@ -541,6 +583,7 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { if (!mounted) return; setState(() { _dismissed = prefs.getBool(_kChecklistDismissedKey) ?? false; + _setupProfile = GuidedSetupDialog.appliedSetupProfile(); _initialised = true; }); if (!_dismissed) { @@ -598,6 +641,13 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { setState(() => _dismissed = true); } + /// Localized plain-language label for the applied setup profile. + String _profileLabel(AppLocalizations l) => switch (_setupProfile) { + 'enterprise' => l.setupProfileEnterprise, + 'air-gapped' => l.setupProfileAirgapped, + _ => l.setupProfileDev, + }; + @override Widget build(BuildContext context) { if (!_initialised || _dismissed) return const SizedBox.shrink(); @@ -674,6 +724,27 @@ class _OnboardingChecklistState extends State<_OnboardingChecklist> { color: theme.colorScheme.onSurfaceVariant, ), ), + // The guided setup already ran: say so, so the checklist + // reads as "what's left", not as a second, disconnected + // onboarding that ignores the assistant's work. + if (_setupProfile != null) ...[ + const SizedBox(height: ChainSpace.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.check_circle, size: 16, color: ChainColors.success), + const SizedBox(width: 6), + Expanded( + child: Text( + l.welcomeChecklistSetupDone(_profileLabel(l)), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ], const SizedBox(height: ChainSpace.md), Container( decoration: BoxDecoration( diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index a5dd4e6..6ad01ff 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -9,14 +9,17 @@ // plain-language summary — never the CLI's English prose. Every // option carries a one-line explanation of what it configures. +import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import '../data/error_presentation.dart'; +import '../data/hub.dart'; import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; +import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; /// A selectable setup option: the stable kebab wire value plus the @@ -29,12 +32,74 @@ class _Option { } class GuidedSetupDialog extends StatefulWidget { - const GuidedSetupDialog({super.key}); + /// The shell, resolved by [show] before the dialog route detaches + /// from the shell's subtree. Drives the post-apply "open the + /// starter flow" navigation; null in tests. + final StudioShellState? shell; - static Future show(BuildContext context) => showDialog( - context: context, - builder: (_) => const GuidedSetupDialog(), - ); + /// Test seam: start on the review step with this parsed plan + /// instead of shelling out to `chain init --plan-json`. + @visibleForTesting + final Map? debugPlan; + + /// Test seam: combined with [debugPlan], start in the applied + /// (next-steps) state. + @visibleForTesting + final bool debugApplied; + + const GuidedSetupDialog({ + super.key, + this.shell, + this.debugPlan, + this.debugApplied = false, + }); + + static Future show(BuildContext context) { + final shell = StudioShellState.of(context); + return showDialog( + context: context, + builder: (_) => GuidedSetupDialog(shell: shell), + ); + } + + /// Path of the operator dir (`~/.chain`), or null when no home + /// directory can be resolved. + static String? _chainDir() { + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home == null) return null; + return '$home${Platform.pathSeparator}.chain'; + } + + /// True on a fresh install: neither a config nor a recorded setup + /// plan exists yet. Drives the welcome page's auto-open. + static bool isFreshInstall() { + final dir = _chainDir(); + if (dir == null) return false; + final sep = Platform.pathSeparator; + return !File('$dir${sep}config.yaml').existsSync() && + !File('$dir${sep}setup-plan.yaml').existsSync(); + } + + /// The `profile:` of the recorded setup plan (`setup-plan.yaml`), + /// or null when the guided setup never applied one. Lets the + /// onboarding checklist say "the assistant covered this" instead + /// of presenting a second, disconnected onboarding surface. + static String? appliedSetupProfile() { + final dir = _chainDir(); + if (dir == null) return null; + final f = File('$dir${Platform.pathSeparator}setup-plan.yaml'); + if (!f.existsSync()) return null; + try { + for (final line in f.readAsLinesSync()) { + final m = RegExp(r'^profile:\s*(\S+)').firstMatch(line.trim()); + if (m != null) return m.group(1); + } + } on FileSystemException { + return null; + } + return null; + } @override State createState() => _GuidedSetupDialogState(); @@ -71,16 +136,37 @@ class _GuidedSetupDialogState extends State { String _target = 'this-laptop'; bool _requireApproval = false; bool _dataLocal = false; + bool _allowUnsigned = false; bool _busy = false; Map? _plan; // parsed SetupPlan from --plan-json bool _applied = false; + // Post-apply action state: hub reachability + per-module install + // progress, so the next steps are buttons that report back instead + // of CLI commands to retype. + bool _hubUp = false; + bool _startingHub = false; + final Set _installing = {}; + final Set _installed = {}; + + @override + void initState() { + super.initState(); + final seeded = widget.debugPlan; + if (seeded != null) { + _plan = seeded; + _step = _totalSteps; + _applied = widget.debugApplied; + } + } + String _answersYaml() => 'scenario: $_scenario\n' 'intent: $_intent\n' 'target: $_target\n' 'require_approval: $_requireApproval\n' - 'data_must_stay_local: $_dataLocal\n'; + 'data_must_stay_local: $_dataLocal\n' + 'allow_unsigned_modules: $_allowUnsigned\n'; Future _writeAnswers() async { final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); @@ -131,11 +217,65 @@ class _GuidedSetupDialogState extends State { setState(() => _busy = false); if (r.ok) { setState(() => _applied = true); + unawaited(_probeHub()); } else { showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr); } } + /// Refresh the "is the hub reachable" signal driving the + /// post-apply action rows. + Future _probeHub() async { + final up = await HubService.instance + .healthy() + .catchError((Object _) => false); + if (mounted) setState(() => _hubUp = up); + } + + /// Start the local daemon, then poll until it answers (the start + /// command returns before the gRPC endpoint is up). A failed start + /// often just means "already running", so probe before reporting. + Future _startHub() async { + setState(() => _startingHub = true); + final r = await SystemActions.chainDaemon(['start']); + var up = false; + for (var i = 0; i < 6 && !up; i++) { + await Future.delayed(const Duration(milliseconds: 700)); + up = await HubService.instance + .healthy() + .catchError((Object _) => false); + } + if (!mounted) return; + setState(() { + _startingHub = false; + _hubUp = up; + }); + if (!up) { + await showFaiProcessErrorDialog( + context, + 'chain daemon start', + r.stdout, + r.stderr, + ); + } + } + + /// Install one plan module by capability name — the hub resolves + /// the bundle URL from its store index. + Future _install(String module) async { + setState(() => _installing.add(module)); + try { + await HubService.instance.installModule(source: module); + if (mounted) setState(() => _installed.add(module)); + } catch (e) { + if (mounted) { + await showChainErrorDialog(context, 'install $module', e); + } + } finally { + if (mounted) setState(() => _installing.remove(module)); + } + } + @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; @@ -210,26 +350,16 @@ class _GuidedSetupDialogState extends State { if (plan == null) return const SizedBox.shrink(); final theme = Theme.of(context); final lines = _planLines(l, plan); + final modules = (plan['modules'] as List?)?.cast() ?? const []; + final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped'; + final sigGate = plan['require_signatures'] == true; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_applied) ...[ - Row( - children: [ - Icon(Icons.check_circle, color: ChainColors.success, size: 20), - const SizedBox(width: ChainSpace.sm), - Text(l.setupApplied, style: theme.textTheme.titleSmall), - ], - ), - const SizedBox(height: ChainSpace.md), - Text(l.setupNextTitle, style: theme.textTheme.labelLarge), - const SizedBox(height: 4), - _next(l.setupNextHubStart), - if ((plan['modules'] as List?)?.isNotEmpty ?? false) - _next(l.setupNextInstall), - _next(l.setupNextRunFlow), - ] else ...[ + if (_applied) + ..._appliedSteps(l, plan) + else ...[ for (final line in lines) Padding( padding: const EdgeInsets.only(bottom: 6), @@ -243,6 +373,44 @@ class _GuidedSetupDialogState extends State { ], ), ), + // Signature dead end (SEC-01): a regulated connected plan + // refuses the public store's unsigned bundles — the very + // installs suggested after apply. Explain it and offer the + // relaxation as one deliberate, reversible switch. The plan + // is re-assembled on toggle so the preview stays truthful. + if (!airGapped && modules.isNotEmpty && (sigGate || _allowUnsigned)) ...[ + const SizedBox(height: ChainSpace.sm), + if (sigGate) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.verified_user_outlined, + size: 16, + color: theme.colorScheme.tertiary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l.setupSigPublicStoreNotice, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.tertiary, + ), + ), + ), + ], + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(l.setupAllowUnsigned), + subtitle: Text(l.setupAllowUnsignedSub), + value: _allowUnsigned, + onChanged: (v) { + setState(() => _allowUnsigned = v); + _goReview(); + }, + ), + ], if (_configExists()) ...[ const SizedBox(height: ChainSpace.sm), Row( @@ -266,13 +434,122 @@ class _GuidedSetupDialogState extends State { ); } - Widget _next(String label) => Padding( - padding: const EdgeInsets.only(top: 4), + /// Post-apply next steps as real Studio actions: start the hub, + /// install each plan module, open the starter flow — no CLI text. + List _appliedSteps(AppLocalizations l, Map plan) { + final theme = Theme.of(context); + final modules = (plan['modules'] as List?)?.cast() ?? const []; + final airGapped = (plan['profile'] as String? ?? '') == 'air-gapped'; + final sigGate = plan['require_signatures'] == true; + final flow = plan['starter_flow'] as String? ?? ''; + return [ + Row( + children: [ + Icon(Icons.check_circle, color: ChainColors.success, size: 20), + const SizedBox(width: ChainSpace.sm), + Text(l.setupApplied, style: theme.textTheme.titleSmall), + ], + ), + const SizedBox(height: ChainSpace.md), + Text(l.setupNextTitle, style: theme.textTheme.labelLarge), + const SizedBox(height: ChainSpace.sm), + // 1 — the hub. Everything else needs it. + if (_hubUp) + _doneRow(l.setupHubRunning) + else + _actionRow( + FilledButton.tonalIcon( + onPressed: _startingHub ? null : _startHub, + icon: _startingHub + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_arrow, size: 18), + label: Text(l.setupNextHubStart), + ), + ), + // 2 — the plan's modules. + if (modules.isNotEmpty) ...[ + if (airGapped) + _hintRow(l.setupModulesOfflineHint) + else if (sigGate) + _hintRow(l.setupModulesSignedHint) + else ...[ + if (!_hubUp) _hintRow(l.setupStartHubFirst), + for (final m in modules) + _installed.contains(m) + ? _doneRow(l.setupActionInstalled(m)) + : _actionRow( + FilledButton.tonalIcon( + onPressed: (!_hubUp || _installing.contains(m)) + ? null + : () => _install(m), + icon: _installing.contains(m) + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download, size: 18), + label: Text(l.setupActionInstall(m)), + ), + ), + ], + ], + // 3 — the starter flow (auto-imported on first hub start). + if (flow.isNotEmpty) + _actionRow( + OutlinedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + widget.shell?.navigateTo('flows'); + }, + icon: const Icon(Icons.account_tree_outlined, size: 18), + label: Text(l.setupActionOpenFlow(flow)), + ), + ), + ]; + } + + Widget _actionRow(Widget child) => Padding( + padding: const EdgeInsets.only(top: ChainSpace.xs), + child: Align(alignment: Alignment.centerLeft, child: child), + ); + + Widget _doneRow(String label) => Padding( + padding: const EdgeInsets.only(top: ChainSpace.xs), child: Row( children: [ - Icon(Icons.arrow_right, size: 18, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 4), - Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), + Icon(Icons.check_circle, size: 18, color: ChainColors.success), + const SizedBox(width: 6), + Expanded( + child: Text(label, style: Theme.of(context).textTheme.bodyMedium), + ), + ], + ), + ); + + Widget _hintRow(String text) => Padding( + padding: const EdgeInsets.only(top: ChainSpace.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), ], ), ); diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 23d2836..326484f 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -26,6 +26,41 @@ Widget _host() => MaterialApp( ), ); +/// Host that opens the dialog pre-seeded on the review / applied +/// step with [plan] (the parsed `--plan-json` shape), bypassing the +/// CLI subprocess. +Widget _seededHost(Map plan, {bool applied = false}) => + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => + GuidedSetupDialog(debugPlan: plan, debugApplied: applied), + ), + child: const Text('open'), + ), + ), + ), + ), + ); + +Map _regulatedPlan({bool signed = true}) => { + 'profile': 'enterprise', + 'modules': ['text.extract', 'text.summarize'], + 'starter_flow': 'extract-summarize', + 'runbook': 'service', + 'curated_docs': [], + 'require_signatures': signed, + 'worm_audit': false, + 'approval_step': true, +}; + void main() { testWidgets('step 1 shows scenario options in German with explanations', ( tester, @@ -75,4 +110,70 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Schritt 2 von 3'), findsOneWidget); }); + + testWidgets( + 'review of a regulated plan explains the signature dead end and offers the relaxation switch', + (tester) async { + await tester.pumpWidget(_seededHost(_regulatedPlan())); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // The plain-language notice + the deliberate one-click switch. + expect( + find.textContaining('Der öffentliche Store liefert zurzeit unsignierte Pakete'), + findsOneWidget, + ); + expect( + find.text('Installation aus dem öffentlichen Store erlauben'), + findsOneWidget, + ); + // No raw CLI prose anywhere. + expect(find.textContaining('chain install'), findsNothing); + }, + ); + + testWidgets( + 'applied state renders clickable next steps — start hub, per-module install, open flow', + (tester) async { + await tester.pumpWidget( + _seededHost(_regulatedPlan(signed: false), applied: true), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.text('Fertig — Ch∆In ist eingerichtet.'), findsOneWidget); + // Hub not probed up in tests → the start action + the + // install-needs-hub hint show, and install buttons exist + // (disabled) for each plan module. + expect(find.text('Hub starten'), findsOneWidget); + expect( + find.textContaining('Starten Sie zuerst den Hub'), + findsOneWidget, + ); + expect(find.text('text.extract installieren'), findsOneWidget); + expect(find.text('text.summarize installieren'), findsOneWidget); + expect( + find.text('Beispiel-Flow „extract-summarize“ öffnen'), + findsOneWidget, + ); + // No CLI text in the GUI path. + expect(find.textContaining('chain serve'), findsNothing); + expect(find.textContaining('chain install'), findsNothing); + }, + ); + + testWidgets( + 'applied regulated (signed) state guides to the signed source instead of dead-end install buttons', + (tester) async { + await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('signierten Quelle'), + findsOneWidget, + ); + expect(find.text('text.extract installieren'), findsNothing); + }, + ); } From 09c901b75ebff798c5f2a075ce8fd6673f609be3 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 23:24:27 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20guided=20setup=20=E2=80=94=20free-t?= =?UTF-8?q?ext=20AI=20path=20(phase=201.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wizard's first step now offers 'or just describe what you want to do': the goal goes to the configured system AI, which maps it onto the menu answers (validated against strict enum whitelists — a hallucinated value can never reach the engine). The suggestion comes back as an editable plain-language reflection ('this is how I read your task') the operator can adjust step-by-step or take to the same preview/apply the menu path uses. Trust rules per guided-setup.md: suggestion only (never auto-apply), a privacy line states whether the description is processed locally or sent to a provider, and without a configured system AI the section explains that the menu always works — no dead end. Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 20 ++ lib/l10n/app_en.arb | 20 ++ lib/l10n/app_localizations.dart | 90 +++++++++ lib/l10n/app_localizations_de.dart | 60 ++++++ lib/l10n/app_localizations_en.dart | 60 ++++++ lib/widgets/guided_setup_dialog.dart | 287 ++++++++++++++++++++++++++- test/guided_setup_test.dart | 84 ++++++++ 7 files changed, 619 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 75b8838..4933d00 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1761,6 +1761,26 @@ "setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben", "setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.", "setupStartCta": "In 3 Fragen loslegen", + "setupFreeTextHint": "z. B.: Eingehende Anträge vorprüfen und unvollständige markieren", + "setupFreeTextSuggest": "Vorschlagen lassen", + "setupFreeTextPrivacyLocal": "Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ({model}).", + "@setupFreeTextPrivacyLocal": {"placeholders": {"model": {"type": "String"}}}, + "setupFreeTextPrivacyRemote": "Ihre Beschreibung wird an die eingerichtete System-KI gesendet: {model} ({provider}).", + "@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}}, + "setupFreeTextUnavailable": "Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.", + "setupFreeTextParseError": "Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.", + "setupReflectionTitle": "So verstehe ich Ihre Aufgabe", + "setupReflectionScenario": "Worum es geht: {label}", + "@setupReflectionScenario": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionIntent": "Erste Aufgabe: {label}", + "@setupReflectionIntent": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionTarget": "Umgebung: {label}", + "@setupReflectionTarget": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionApproval": "Vor jedem KI-Schritt wird ein Mensch um Freigabe gebeten.", + "setupReflectionDataLocal": "Daten bleiben vollständig im Haus.", + "setupReflectionEditHint": "Es wird nichts ohne Ihre Bestätigung geändert. Passen Sie die Punkte an oder gehen Sie direkt zur Vorschau.", + "setupReflectionAdjust": "Anpassen", + "setupReflectionToPreview": "Zur Vorschau", "setupProfileDev": "unkomplizierter Arbeitsplatz", "setupProfileEnterprise": "abgesicherter Team-Betrieb", "setupProfileAirgapped": "vollständig lokaler, regulierter Betrieb", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6314d57..d8c90fa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1800,6 +1800,26 @@ "setupAllowUnsigned": "Allow installing from the public store", "setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.", "setupStartCta": "Get started in 3 questions", + "setupFreeTextHint": "e.g.: pre-screen incoming applications and flag incomplete ones", + "setupFreeTextSuggest": "Suggest a setup", + "setupFreeTextPrivacyLocal": "Your description is processed locally on this machine ({model}).", + "@setupFreeTextPrivacyLocal": {"placeholders": {"model": {"type": "String"}}}, + "setupFreeTextPrivacyRemote": "Your description is sent to the configured system AI: {model} ({provider}).", + "@setupFreeTextPrivacyRemote": {"placeholders": {"model": {"type": "String"}, "provider": {"type": "String"}}}, + "setupFreeTextUnavailable": "The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.", + "setupFreeTextParseError": "The system AI did not return a usable suggestion. Pick from the menu above — or try again.", + "setupReflectionTitle": "This is how I read your task", + "setupReflectionScenario": "What it is about: {label}", + "@setupReflectionScenario": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionIntent": "First task: {label}", + "@setupReflectionIntent": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionTarget": "Environment: {label}", + "@setupReflectionTarget": {"placeholders": {"label": {"type": "String"}}}, + "setupReflectionApproval": "A human is asked for approval before every AI step.", + "setupReflectionDataLocal": "Data never leaves the building.", + "setupReflectionEditHint": "Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.", + "setupReflectionAdjust": "Adjust", + "setupReflectionToPreview": "To the preview", "setupProfileDev": "straightforward workstation", "setupProfileEnterprise": "hardened team operation", "setupProfileAirgapped": "fully local, regulated operation", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5b7ec8e..7b44495 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5395,6 +5395,96 @@ abstract class AppLocalizations { /// **'Get started in 3 questions'** String get setupStartCta; + /// No description provided for @setupFreeTextHint. + /// + /// In en, this message translates to: + /// **'e.g.: pre-screen incoming applications and flag incomplete ones'** + String get setupFreeTextHint; + + /// No description provided for @setupFreeTextSuggest. + /// + /// In en, this message translates to: + /// **'Suggest a setup'** + String get setupFreeTextSuggest; + + /// No description provided for @setupFreeTextPrivacyLocal. + /// + /// In en, this message translates to: + /// **'Your description is processed locally on this machine ({model}).'** + String setupFreeTextPrivacyLocal(String model); + + /// No description provided for @setupFreeTextPrivacyRemote. + /// + /// In en, this message translates to: + /// **'Your description is sent to the configured system AI: {model} ({provider}).'** + String setupFreeTextPrivacyRemote(String model, String provider); + + /// No description provided for @setupFreeTextUnavailable. + /// + /// In en, this message translates to: + /// **'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'** + String get setupFreeTextUnavailable; + + /// No description provided for @setupFreeTextParseError. + /// + /// In en, this message translates to: + /// **'The system AI did not return a usable suggestion. Pick from the menu above — or try again.'** + String get setupFreeTextParseError; + + /// No description provided for @setupReflectionTitle. + /// + /// In en, this message translates to: + /// **'This is how I read your task'** + String get setupReflectionTitle; + + /// No description provided for @setupReflectionScenario. + /// + /// In en, this message translates to: + /// **'What it is about: {label}'** + String setupReflectionScenario(String label); + + /// No description provided for @setupReflectionIntent. + /// + /// In en, this message translates to: + /// **'First task: {label}'** + String setupReflectionIntent(String label); + + /// No description provided for @setupReflectionTarget. + /// + /// In en, this message translates to: + /// **'Environment: {label}'** + String setupReflectionTarget(String label); + + /// No description provided for @setupReflectionApproval. + /// + /// In en, this message translates to: + /// **'A human is asked for approval before every AI step.'** + String get setupReflectionApproval; + + /// No description provided for @setupReflectionDataLocal. + /// + /// In en, this message translates to: + /// **'Data never leaves the building.'** + String get setupReflectionDataLocal; + + /// No description provided for @setupReflectionEditHint. + /// + /// In en, this message translates to: + /// **'Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.'** + String get setupReflectionEditHint; + + /// No description provided for @setupReflectionAdjust. + /// + /// In en, this message translates to: + /// **'Adjust'** + String get setupReflectionAdjust; + + /// No description provided for @setupReflectionToPreview. + /// + /// In en, this message translates to: + /// **'To the preview'** + String get setupReflectionToPreview; + /// No description provided for @setupProfileDev. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2c3bc1b..ef59997 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3182,6 +3182,66 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupStartCta => 'In 3 Fragen loslegen'; + @override + String get setupFreeTextHint => + 'z. B.: Eingehende Anträge vorprüfen und unvollständige markieren'; + + @override + String get setupFreeTextSuggest => 'Vorschlagen lassen'; + + @override + String setupFreeTextPrivacyLocal(String model) { + return 'Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ($model).'; + } + + @override + String setupFreeTextPrivacyRemote(String model, String provider) { + return 'Ihre Beschreibung wird an die eingerichtete System-KI gesendet: $model ($provider).'; + } + + @override + String get setupFreeTextUnavailable => + 'Für den Freitext-Weg braucht Ch∆In eine eingerichtete System-KI (Einstellungen → System-KI). Die Auswahl oben funktioniert immer — ganz ohne KI.'; + + @override + String get setupFreeTextParseError => + 'Die System-KI hat keinen verwertbaren Vorschlag geliefert. Wählen Sie oben aus dem Menü — oder versuchen Sie es noch einmal.'; + + @override + String get setupReflectionTitle => 'So verstehe ich Ihre Aufgabe'; + + @override + String setupReflectionScenario(String label) { + return 'Worum es geht: $label'; + } + + @override + String setupReflectionIntent(String label) { + return 'Erste Aufgabe: $label'; + } + + @override + String setupReflectionTarget(String label) { + return 'Umgebung: $label'; + } + + @override + String get setupReflectionApproval => + 'Vor jedem KI-Schritt wird ein Mensch um Freigabe gebeten.'; + + @override + String get setupReflectionDataLocal => 'Daten bleiben vollständig im Haus.'; + + @override + String get setupReflectionEditHint => + 'Es wird nichts ohne Ihre Bestätigung geändert. Passen Sie die Punkte an oder gehen Sie direkt zur Vorschau.'; + + @override + String get setupReflectionAdjust => 'Anpassen'; + + @override + String get setupReflectionToPreview => 'Zur Vorschau'; + @override String get setupProfileDev => 'unkomplizierter Arbeitsplatz'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 4342406..f114fa4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3179,6 +3179,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupStartCta => 'Get started in 3 questions'; + @override + String get setupFreeTextHint => + 'e.g.: pre-screen incoming applications and flag incomplete ones'; + + @override + String get setupFreeTextSuggest => 'Suggest a setup'; + + @override + String setupFreeTextPrivacyLocal(String model) { + return 'Your description is processed locally on this machine ($model).'; + } + + @override + String setupFreeTextPrivacyRemote(String model, String provider) { + return 'Your description is sent to the configured system AI: $model ($provider).'; + } + + @override + String get setupFreeTextUnavailable => + 'The free-text path needs a configured system AI (Settings → System AI). The choices above always work — no AI required.'; + + @override + String get setupFreeTextParseError => + 'The system AI did not return a usable suggestion. Pick from the menu above — or try again.'; + + @override + String get setupReflectionTitle => 'This is how I read your task'; + + @override + String setupReflectionScenario(String label) { + return 'What it is about: $label'; + } + + @override + String setupReflectionIntent(String label) { + return 'First task: $label'; + } + + @override + String setupReflectionTarget(String label) { + return 'Environment: $label'; + } + + @override + String get setupReflectionApproval => + 'A human is asked for approval before every AI step.'; + + @override + String get setupReflectionDataLocal => 'Data never leaves the building.'; + + @override + String get setupReflectionEditHint => + 'Nothing is changed without your confirmation. Adjust any point, or go straight to the preview.'; + + @override + String get setupReflectionAdjust => 'Adjust'; + + @override + String get setupReflectionToPreview => 'To the preview'; + @override String get setupProfileDev => 'straightforward workstation'; diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index 6ad01ff..9ca9ed7 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -22,6 +22,61 @@ import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; +/// Allowed wire values per answer — an AI suggestion is validated +/// against these; anything else is rejected as a parse failure so a +/// hallucinated enum can never reach the engine. +const _kScenarioValues = [ + 'trying-out', + 'team-hub', + 'regulated-production', + 'building-modules', +]; +const _kIntentValues = [ + 'hello-world', + 'extract-text', + 'extract-summarize', + 'classify-documents', + 'build-module', +]; +const _kTargetValues = [ + 'this-laptop', + 'home-server', + 'air-gapped-server', + 'container', +]; + +/// Extract + validate the system AI's setup suggestion: the first +/// JSON object in [text] with valid enum values for all three +/// answers. Returns null when nothing safe could be parsed. +@visibleForTesting +Map? parseSetupSuggestion(String text) { + final start = text.indexOf('{'); + final end = text.lastIndexOf('}'); + if (start < 0 || end <= start) return null; + final Object? decoded; + try { + decoded = jsonDecode(text.substring(start, end + 1)); + } on FormatException { + return null; + } + if (decoded is! Map) return null; + final scenario = decoded['scenario']; + final intent = decoded['intent']; + final target = decoded['target']; + if (!_kScenarioValues.contains(scenario) || + !_kIntentValues.contains(intent) || + !_kTargetValues.contains(target)) { + return null; + } + return { + 'scenario': scenario, + 'intent': intent, + 'target': target, + 'require_approval': decoded['require_approval'] == true, + 'data_must_stay_local': decoded['data_must_stay_local'] == true, + }; +} + /// A selectable setup option: the stable kebab wire value plus the /// localized label + one-line explanation resolved at build time. class _Option { @@ -47,11 +102,17 @@ class GuidedSetupDialog extends StatefulWidget { @visibleForTesting final bool debugApplied; + /// Test seam: skip the live system-AI probe so the free-text + /// section renders its deterministic "no AI configured" state. + @visibleForTesting + final bool debugSkipAiProbe; + const GuidedSetupDialog({ super.key, this.shell, this.debugPlan, this.debugApplied = false, + this.debugSkipAiProbe = false, }); static Future show(BuildContext context) { @@ -149,6 +210,15 @@ class _GuidedSetupDialogState extends State { final Set _installing = {}; final Set _installed = {}; + // Free-text (AI) path state. The menu path is always complete on + // its own; the AI merely PRE-SELECTS answers for review — it never + // applies anything (trust rule from guided-setup.md). + final TextEditingController _goalCtl = TextEditingController(); + SystemAiStatus? _aiStatus; // null until probed / when unreachable + bool _aiProbed = false; + bool _suggestBusy = false; + bool _showReflection = false; + @override void initState() { super.initState(); @@ -157,9 +227,36 @@ class _GuidedSetupDialogState extends State { _plan = seeded; _step = _totalSteps; _applied = widget.debugApplied; + _aiProbed = true; // tests: skip the live probe + } else if (widget.debugSkipAiProbe) { + _aiProbed = true; + } else { + unawaited(_probeAi()); } } + @override + void dispose() { + _goalCtl.dispose(); + super.dispose(); + } + + /// Probe whether a system AI is configured — the free-text path + /// needs one; without it the menu path stands alone (no dead end). + Future _probeAi() async { + SystemAiStatus? status; + try { + status = await HubService.instance.systemAiStatus(); + } catch (_) { + status = null; + } + if (!mounted) return; + setState(() { + _aiStatus = status; + _aiProbed = true; + }); + } + String _answersYaml() => 'scenario: $_scenario\n' 'intent: $_intent\n' @@ -260,6 +357,60 @@ class _GuidedSetupDialogState extends State { } } + /// Structured prompt for the system AI. English, fixed shape, and + /// the reply is validated against the enum whitelists — the model + /// only ever pre-selects menu answers, it cannot inject config. + String _suggestionPrompt(String goal) => + 'You configure the Ch∆In workflow platform. Map the operator\'s ' + 'goal to setup answers.\n' + 'Goal: """$goal"""\n' + 'Reply with ONLY one JSON object, no prose, of this exact shape:\n' + '{"scenario":"trying-out|team-hub|regulated-production|building-modules",' + '"intent":"hello-world|extract-text|extract-summarize|classify-documents|build-module",' + '"target":"this-laptop|home-server|air-gapped-server|container",' + '"require_approval":true|false,"data_must_stay_local":true|false}\n' + 'Pick the closest match for each field.'; + + /// Ask the system AI to map the free-text goal onto the menu + /// answers, then show the editable reflection. Never applies — + /// the suggestion only pre-selects; preview + apply stay manual. + Future _suggest() async { + final goal = _goalCtl.text.trim(); + if (goal.isEmpty) return; + setState(() => _suggestBusy = true); + final l = AppLocalizations.of(context)!; + try { + final r = await HubService.instance.askAi(_suggestionPrompt(goal)); + if (!mounted) return; + setState(() => _suggestBusy = false); + if (r.errorKind.isNotEmpty) { + await showChainErrorDialog(context, 'system-ai', r.text); + return; + } + final parsed = parseSetupSuggestion(r.text); + if (parsed == null) { + await showChainErrorDialog( + context, + 'system-ai', + '${l.setupFreeTextParseError}\n\n${r.text}', + ); + return; + } + setState(() { + _scenario = parsed['scenario'] as String; + _intent = parsed['intent'] as String; + _target = parsed['target'] as String; + _requireApproval = parsed['require_approval'] as bool; + _dataLocal = parsed['data_must_stay_local'] as bool; + _showReflection = true; + }); + } catch (e) { + if (!mounted) return; + setState(() => _suggestBusy = false); + await showChainErrorDialog(context, 'system-ai', e); + } + } + /// Install one plan module by capability name — the hub resolves /// the bundle URL from its store index. Future _install(String module) async { @@ -280,12 +431,21 @@ class _GuidedSetupDialogState extends State { Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final reviewing = _step >= _totalSteps; + final title = reviewing + ? l.setupReviewTitle + : _showReflection + ? l.setupReflectionTitle + : _stepTitle(l); return AlertDialog( - title: Text(reviewing ? l.setupReviewTitle : _stepTitle(l)), + title: Text(title), content: SizedBox( width: 480, child: SingleChildScrollView( - child: reviewing ? _reviewStep(l) : _answerStep(l), + child: reviewing + ? _reviewStep(l) + : _showReflection + ? _reflectionView(l) + : _answerStep(l), ), ), actions: _actions(l), @@ -341,6 +501,112 @@ class _GuidedSetupDialogState extends State { onChanged: (v) => setState(() => _dataLocal = v), ), ], + // The free-text alternative lives on the first step: describe + // the goal, the system AI pre-selects the menu answers. Menu + // path always stands alone (air-gap-safe, no dead end). + if (_step == 0 && _aiProbed) ...[ + const SizedBox(height: ChainSpace.md), + Text( + l.setupChooseFreeText, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: ChainSpace.sm), + if (_aiStatus?.enabled == true) ...[ + TextField( + controller: _goalCtl, + minLines: 2, + maxLines: 4, + decoration: InputDecoration( + hintText: l.setupFreeTextHint, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: ChainSpace.xs), + // Trust rule: be transparent about where the description + // goes before the operator types anything sensitive. + Text( + _aiIsLocal() + ? l.setupFreeTextPrivacyLocal(_aiStatus?.model ?? '') + : l.setupFreeTextPrivacyRemote( + _aiStatus?.model ?? '', + _aiStatus?.provider ?? '', + ), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: ChainSpace.sm), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: _suggestBusy ? null : _suggest, + icon: _suggestBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome, size: 18), + label: Text(l.setupFreeTextSuggest), + ), + ), + ] else + _hintRow(l.setupFreeTextUnavailable), + ], + ], + ); + } + + /// True when the configured system AI runs on this machine — the + /// privacy line says so instead of naming a cloud provider. + bool _aiIsLocal() { + final s = _aiStatus; + if (s == null) return false; + return s.provider == 'ollama' || + s.endpoint.contains('localhost') || + s.endpoint.contains('127.0.0.1'); + } + + /// Editable reflection of the AI suggestion: "this is how I read + /// your task", in the same localized labels the menu uses. The + /// operator either adjusts (steps, pre-selected) or goes straight + /// to the same preview the menu path uses. Nothing auto-applies. + Widget _reflectionView(AppLocalizations l) { + final theme = Theme.of(context); + String labelFor(List<_Option> opts, String value) => + opts.firstWhere((o) => o.value == value, orElse: () => opts.first) + .label(l); + final lines = [ + l.setupReflectionScenario(labelFor(_scenarios, _scenario)), + l.setupReflectionIntent(labelFor(_intents, _intent)), + l.setupReflectionTarget(labelFor(_targets, _target)), + if (_requireApproval) l.setupReflectionApproval, + if (_dataLocal) l.setupReflectionDataLocal, + ]; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final line in lines) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('· ', style: theme.textTheme.bodyMedium), + Expanded( + child: Text(line, style: theme.textTheme.bodyMedium), + ), + ], + ), + ), + const SizedBox(height: ChainSpace.xs), + Text( + l.setupReflectionEditHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ], ); } @@ -598,6 +864,23 @@ class _GuidedSetupDialogState extends State { ), ]; } + if (_showReflection && _step < _totalSteps) { + // AI reflection: adjust step-by-step (answers stay pre-selected) + // or continue to the same preview the menu path uses. + return [ + TextButton( + onPressed: () => setState(() => _showReflection = false), + child: Text(l.setupReflectionAdjust), + ), + FilledButton( + onPressed: () { + setState(() => _showReflection = false); + _goReview(); + }, + child: Text(l.setupReflectionToPreview), + ), + ]; + } if (_step < _totalSteps) { return [ TextButton( diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 326484f..0faf348 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -176,4 +176,88 @@ void main() { expect(find.text('text.extract installieren'), findsNothing); }, ); + + testWidgets( + 'step 1 offers the free-text path with an honest no-AI fallback hint', + (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () => showDialog( + context: context, + builder: (_) => + const GuidedSetupDialog(debugSkipAiProbe: true), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.text('Oder beschreiben Sie einfach, was Sie vorhaben'), + findsOneWidget, + ); + // No system AI configured → honest fallback: the menu always + // works; no dead end, no disabled mystery button. + expect( + find.textContaining('Die Auswahl oben funktioniert immer'), + findsOneWidget, + ); + expect(find.text('Vorschlagen lassen'), findsNothing); + }, + ); + + group('parseSetupSuggestion', () { + test('accepts a valid JSON object embedded in prose', () { + final r = parseSetupSuggestion( + 'Sure! Here is the mapping:\n' + '{"scenario":"regulated-production","intent":"classify-documents",' + '"target":"home-server","require_approval":true,' + '"data_must_stay_local":false}\n' + 'Let me know if you need anything else.', + ); + expect(r, isNotNull); + expect(r!['scenario'], 'regulated-production'); + expect(r['intent'], 'classify-documents'); + expect(r['target'], 'home-server'); + expect(r['require_approval'], true); + expect(r['data_must_stay_local'], false); + }); + + test('rejects hallucinated enum values', () { + expect( + parseSetupSuggestion( + '{"scenario":"world-domination","intent":"hello-world",' + '"target":"this-laptop"}', + ), + isNull, + ); + }); + + test('rejects non-JSON replies', () { + expect(parseSetupSuggestion('I cannot help with that.'), isNull); + expect(parseSetupSuggestion('{broken json'), isNull); + }); + + test('missing booleans default to false', () { + final r = parseSetupSuggestion( + '{"scenario":"trying-out","intent":"hello-world",' + '"target":"this-laptop"}', + ); + expect(r, isNotNull); + expect(r!['require_approval'], false); + expect(r['data_must_stay_local'], false); + }); + }); } From ed2c5ace26160b5615ed65841c43a9e49a6189ba Mon Sep 17 00:00:00 2001 From: flemming-it Date: Sun, 12 Jul 2026 23:27:49 +0200 Subject: [PATCH 3/6] test: generated nav manifest guards the sidebar truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test/nav_manifest_test.dart derives docs/nav.generated.json from the _pages list (order = Cmd numbers), the labelOf switch, and both .arb files. On any nav change it regenerates the manifest and fails once with instructions to commit + mirror it to fai_chain/docs/studio/, where the platform repo's docs_consistency gate checks the operator guide against it — cross-repo nav drift becomes a red gate instead of quietly rotting docs. Signed-off-by: flemming-it --- docs/nav.generated.json | 53 ++++++++++++++++ test/nav_manifest_test.dart | 118 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/nav.generated.json create mode 100644 test/nav_manifest_test.dart diff --git a/docs/nav.generated.json b/docs/nav.generated.json new file mode 100644 index 0000000..0a8ff7f --- /dev/null +++ b/docs/nav.generated.json @@ -0,0 +1,53 @@ +{ + "_generated": "by test/nav_manifest_test.dart — do not edit. Mirror to fai_chain/docs/studio/nav.generated.json (same content).", + "pages": [ + { + "id": "welcome", + "cmd": 1, + "label_de": "Willkommen", + "label_en": "Welcome" + }, + { + "id": "store", + "cmd": 2, + "label_de": "Store", + "label_en": "Store" + }, + { + "id": "doctor", + "cmd": 3, + "label_de": "Diagnose", + "label_en": "Doctor" + }, + { + "id": "flows", + "cmd": 4, + "label_de": "Flows", + "label_en": "Flows" + }, + { + "id": "audit", + "cmd": 5, + "label_de": "Protokoll", + "label_en": "Audit" + }, + { + "id": "approvals", + "cmd": 6, + "label_de": "Freigaben", + "label_en": "Approvals" + }, + { + "id": "runs", + "cmd": 7, + "label_de": "Läufe", + "label_en": "Runs" + }, + { + "id": "federation", + "cmd": 8, + "label_de": "Föderation", + "label_en": "Federation" + } + ] +} diff --git a/test/nav_manifest_test.dart b/test/nav_manifest_test.dart new file mode 100644 index 0000000..1646110 --- /dev/null +++ b/test/nav_manifest_test.dart @@ -0,0 +1,118 @@ +// Nav-manifest guard. The sidebar truth lives in three places in +// this repo: the `_pages` list (ids + order = Cmd numbers), the +// `labelOf` switch (id → l10n key), and the two .arb files (key → +// DE/EN label). The operator guide documenting that nav lives in +// the PLATFORM repo (fai_chain/docs/studio/operator-guide.md), +// which this repo's CI cannot see — so the truth is exported as a +// committed manifest, `docs/nav.generated.json`, and the platform +// repo's docs_consistency test checks the guide against it. +// +// This test derives the manifest from the sources. When it differs +// from the committed file it REWRITES the file and fails once with +// instructions — commit the regenerated manifest and mirror it to +// fai_chain/docs/studio/nav.generated.json so the cross-repo guard +// stays truthful. Add/rename/reorder a page and forget the guide: +// the platform gate goes loudly red instead of the docs quietly +// rotting. + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +const _manifestPath = 'docs/nav.generated.json'; +const _mirrorHint = + 'fai_chain/docs/studio/nav.generated.json (same content)'; + +/// Extract the ordered page ids from the `_pages` list in main.dart. +List _pageIds(String mainDart) { + final block = RegExp( + r'static const _pages = <_NavPage>\[(.*?)\n \];', + dotAll: true, + ).firstMatch(mainDart); + if (block == null) { + fail('could not locate the _pages block in lib/main.dart'); + } + return RegExp(r"id: '([a-z-]+)'") + .allMatches(block.group(1)!) + .map((m) => m.group(1)!) + .toList(); +} + +/// Extract the id → l10n-key mapping from the `labelOf` switch. +Map _labelKeys(String mainDart) { + final keys = {}; + for (final m in RegExp( + r"case '([a-z-]+)':\s*\n\s*return l\.(nav\w+);", + ).allMatches(mainDart)) { + keys[m.group(1)!] = m.group(2)!; + } + if (keys.isEmpty) { + fail('could not parse the labelOf switch in lib/main.dart'); + } + return keys; +} + +Map _arb(String path) => + jsonDecode(File(path).readAsStringSync()) as Map; + +void main() { + test('docs/nav.generated.json matches the sidebar truth', () { + final mainDart = File('lib/main.dart').readAsStringSync(); + final ids = _pageIds(mainDart); + final labelKeys = _labelKeys(mainDart); + final de = _arb('lib/l10n/app_de.arb'); + final en = _arb('lib/l10n/app_en.arb'); + + final pages = >[]; + for (var i = 0; i < ids.length; i++) { + final key = labelKeys[ids[i]]; + expect( + key, + isNotNull, + reason: + "page '${ids[i]}' has no labelOf case — add it so the " + 'sidebar can localize the label', + ); + final labelDe = de[key] as String?; + final labelEn = en[key] as String?; + expect( + labelDe, + isNotNull, + reason: "l10n key '$key' missing from app_de.arb", + ); + expect( + labelEn, + isNotNull, + reason: "l10n key '$key' missing from app_en.arb", + ); + pages.add({ + 'id': ids[i], + 'cmd': i + 1, + 'label_de': labelDe, + 'label_en': labelEn, + }); + } + + const encoder = JsonEncoder.withIndent(' '); + final manifest = + '${encoder.convert({ + '_generated': 'by test/nav_manifest_test.dart — do not edit. ' + 'Mirror to $_mirrorHint.', + 'pages': pages, + })}\n'; + + final file = File(_manifestPath); + final current = file.existsSync() ? file.readAsStringSync() : null; + if (current != manifest) { + file.parent.createSync(recursive: true); + file.writeAsStringSync(manifest); + fail( + 'the sidebar nav changed: $_manifestPath was regenerated. ' + 'Commit it, mirror it to $_mirrorHint, and update the ' + 'operator guide — the platform repo\'s docs_consistency ' + 'test checks the guide against this manifest.', + ); + } + }); +} From cf4024a4e255d7d05b2340cbba750d2bff31a4f6 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 13 Jul 2026 00:47:41 +0200 Subject: [PATCH 4/6] test: deterministic operator-guide screenshot harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit integration_test/guide_shots_test.dart boots a hermetic hub (HubFixture), seeds demo projects (open Bürgeramt; sealed Ratsinformation and a setup.applied audit event only when the runner confirms an isolated $HOME), launches the app in German + dark mode, walks every sidebar page in Cmd order with content-aware waits, opens the workspace switcher and the setup wizard, and writes the guide PNGs via a driverless RepaintBoundary capture. Driven by the platform repo's scripts/regen-studio-guide.sh. Also fixes the hub fixture's binary resolution, dead since the rename (it looked for 'fai' and ../chain_platform/): now $CHAIN_BIN, 'chain' on PATH, then ../fai_chain/target/{release,debug}/chain — the integration tests actually run again instead of silently skipping. Signed-off-by: flemming-it --- CHANGELOG.md | 37 ++++ integration_test/guide_shots_test.dart | 242 +++++++++++++++++++++++++ macos/Podfile.lock | 2 +- pubspec.lock | 39 ++++ pubspec.yaml | 2 + test/integration/hub_fixture.dart | 40 ++-- 6 files changed, 346 insertions(+), 16 deletions(-) create mode 100644 integration_test/guide_shots_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ca0b13..8d36537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Added (guided setup on grade-1 — steps A4/A5/B1 + doc automation) + +- **Clickable next steps.** After apply, the wizard renders real + Studio actions instead of CLI text: start-hub button (polls until + the daemon answers), per-module install buttons with progress/done + states (capability-name install via the hub's store index), and an + open-the-starter-flow button navigating to Flows. +- **Signature dead end resolved.** Regulated plans explain in plain + language that modules come from a signed source; the preview offers + "allow installing from the public store" as one deliberate, + reversible switch (`allow_unsigned_modules`) that re-assembles the + plan. Air-gapped plans point to the offline-bundle path instead. +- **Fresh-install auto-open + honest framing.** On a fresh hub (no + config, no `setup-plan.yaml`) the wizard opens by itself, once per + run; the welcome CTA is now "In 3 Fragen loslegen" / "Get started + in 3 questions". After the wizard closes, the onboarding checklist + re-probes and states what the assistant already covered (applied + profile from `setup-plan.yaml`). +- **Free-text AI path (phase 1.2).** "Or just describe what you want + to do": the goal goes to the configured system AI, the reply is + validated against strict enum whitelists and comes back as an + editable "this is how I read your task" reflection feeding the same + preview/apply. Privacy line states local vs. provider processing; + without a system AI the menu path stands alone. +- **Nav manifest guard.** `test/nav_manifest_test.dart` generates + `docs/nav.generated.json` from the sidebar truth (ids, order = Cmd + numbers, DE+EN labels); the platform repo checks the operator guide + against the mirrored copy. +- **Guide screenshot harness.** `integration_test/guide_shots_test.dart` + boots a hermetic hub, seeds demo projects, walks every nav page, + the workspace switcher and the setup wizard, and writes the guide + PNGs — driven by the platform repo's `scripts/regen-studio-guide.sh`. +- **Fixed:** the integration-test hub fixture still looked for the + pre-rename `fai` binary and `chain_platform/` path, so its tests + silently skipped since the rename; it now resolves `$CHAIN_BIN`, + `chain` on PATH, and `../fai_chain/target/{release,debug}/chain`. + ### Added (multi-project, stage ③ — sealed areas) - **Sealed-area connection switch.** The workspace switcher now lists diff --git a/integration_test/guide_shots_test.dart b/integration_test/guide_shots_test.dart new file mode 100644 index 0000000..acb8f57 --- /dev/null +++ b/integration_test/guide_shots_test.dart @@ -0,0 +1,242 @@ +// Deterministic operator-guide screenshot harness (one command +// instead of the fragile cliclick/screencapture procedure). +// +// Boots a hermetic hub (HubFixture: temp data dir, free port), +// seeds deterministic demo data via the CLI (an open "Bürgeramt" +// project; optionally a sealed "Ratsinformation" when the runner +// confirms an isolated $HOME), launches the real app in German + +// dark mode against that hub, walks every sidebar destination in +// nav order, opens the workspace switcher and the guided-setup +// wizard, and writes the guide PNGs via a RepaintBoundary capture +// (driverless — works headed on macOS with plain `flutter test`). +// +// Run through the platform repo's scripts/regen-studio-guide.sh, +// which isolates $HOME, builds the hub binary, and copies the +// PNGs into fai_chain/docs/studio/images/. Direct invocation: +// +// flutter test integration_test/guide_shots_test.dart -d macos +// +// Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is +// set in the environment. +// +// Hermeticity notes: +// - SharedPreferences is mocked empty — the operator's real Studio +// prefs are neither read nor written. +// - The app reconnects to the fixture hub with persist: false. +// - The sealed seed writes to $HOME/.chain/sealed/ by design, so +// it only runs when CHAIN_GUIDE_SHOTS_SEALED=1 confirms the +// caller isolated $HOME (the regen script does). + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart'; +import 'package:chain_studio/widgets/widgets.dart'; + +import '../test/integration/hub_fixture.dart'; + +final GlobalKey _shotKey = GlobalKey(); + +/// Pump a fixed number of frames instead of pumpAndSettle: the +/// sidebar clock ticks every second, so the tree never settles and +/// pumpAndSettle degenerates into its multi-minute timeout. +Future _pumpFrames(WidgetTester tester, [int frames = 20]) async { + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +/// Pump until [finder] matches (page content arrived) or ~10 s +/// pass — pages load their data from the hub asynchronously, and a +/// blind fixed wait raced the slower ones (the flows editor). +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + int maxFrames = 100, +}) async { + for (var i = 0; i < maxFrames; i++) { + if (finder.evaluate().isNotEmpty) return; + await tester.pump(const Duration(milliseconds: 100)); + } +} + +String get _outDir => + Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots'; + +/// Rasterize the app's RepaintBoundary into `/.png`. +Future _shot(WidgetTester tester, String name) async { + // Two extra frames so ripples/route transitions settle visually. + await tester.pump(const Duration(milliseconds: 120)); + await tester.pump(const Duration(milliseconds: 120)); + final boundary = + _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2.0); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File('$_outDir/$name.png'); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('guide-shot: ${file.path}'); +} + +Future _cli( + String binary, + HubFixture fixture, + List args, +) async { + final r = await Process.run( + binary, + args, + environment: { + ...Platform.environment, + 'CHAIN_DATA_DIR': fixture.tempDir.path, + 'CHAIN_MODULES_DIR': '${fixture.tempDir.path}/modules', + }, + ); + if (r.exitCode != 0) { + fail('seed command `chain ${args.join(' ')}` failed: ${r.stderr}'); + } +} + +/// The guide's image set, in sidebar (= Cmd number) order. Must +/// stay aligned with docs/nav.generated.json — the nav guard in +/// the platform repo keeps the guide itself honest. +const _pageShots = [ + ('welcome', '01-willkommen'), + ('store', '02-store'), + ('doctor', '03-diagnose'), + ('flows', '04-flows'), + ('audit', '05-protokoll'), + ('approvals', '06-freigaben'), + ('runs', '07-laeufe'), + ('federation', '08-foederation'), +]; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('capture the operator-guide screenshots', (tester) async { + final fixture = await HubFixture.start(skipIfBinaryMissing: false); + addTearDown(fixture!.dispose); + final binary = (await HubFixture.binaryPath())!; + + // Deterministic demo data. The slug is explicit — the derived + // one would drop the umlaut ("brgeramt"). + await _cli(binary, fixture, [ + 'project', + 'create', + 'Bürgeramt', + '--slug', + 'buergeramt', + '--color', + '#2e8f9e', + ]); + // The sealed area writes a manifest under $HOME/.chain/sealed/, + // so it needs the runner's confirmation that $HOME is isolated. + final sealedSeed = + Platform.environment['CHAIN_GUIDE_SHOTS_SEALED'] == '1'; + if (sealedSeed) { + await _cli(binary, fixture, [ + 'project', + 'create', + 'Ratsinformation', + '--slug', + 'ratsinformation', + '--isolation', + 'sealed', + ]); + // Seed one audit event the guide can show (setup.applied) by + // applying a minimal guided setup against the fixture's data + // dir. Writes $HOME/.chain/config.yaml, hence the isolated- + // $HOME guard: only the regen script's throwaway HOME is + // ever touched. + final answers = File('${fixture.tempDir.path}/seed-answers.yaml'); + answers.writeAsStringSync( + 'scenario: trying-out\nintent: hello-world\ntarget: this-laptop\n', + ); + await _cli(binary, fixture, [ + 'init', + '--answers', + answers.path, + '--apply', + '--force', + ]); + } + + // Hermetic prefs: never read or write the operator's real ones. + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + RepaintBoundary( + key: _shotKey, + child: const StudioApp( + initialThemeMode: ThemeModeValue.dark, + initialLocale: Locale('de'), + ), + ), + ); + await HubService.instance.reconnect( + HubEndpoint(host: '127.0.0.1', port: fixture.port), + authToken: null, + persist: false, + ); + await _pumpFrames(tester, 30); + + final shell = + tester.state(find.byType(StudioShell)); + + // 01–08: every sidebar destination, in Cmd order. Where a page + // loads slower content from the hub, wait for a sentinel that + // proves the content arrived before capturing. + for (final (id, shotName) in _pageShots) { + shell.navigateTo(id); + await _pumpFrames(tester); + if (id == 'flows') { + // The imported sample flows populate the file list. + await _pumpUntil(tester, find.textContaining('extract-summarize')); + } else if (id == 'audit') { + // The seeded setup.applied event (isolated-$HOME runs). + await _pumpUntil(tester, find.textContaining('setup.applied')); + } + await _shot(tester, shotName); + } + + // 09: the workspace switcher, opened (shows the demo projects; + // with the sealed seed also the shield entry). + shell.navigateTo('audit'); + await _pumpFrames(tester); + final switcher = find.byType(ChainWorkspaceSwitcher); + if (switcher.evaluate().isNotEmpty) { + await tester.tap(switcher.first); + await _pumpFrames(tester); + await _shot(tester, '09-projekt-umschalter'); + // Close the menu again before moving on. + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await _pumpFrames(tester, 5); + } + + // 11: the guided-setup wizard, step 1 (localized option cards). + // 10 (inside a sealed area) still needs the manual procedure — + // it requires switching to a second, isolated hub instance. + shell.navigateTo('welcome'); + await _pumpFrames(tester); + final cta = find.text('In 3 Fragen loslegen'); + if (cta.evaluate().isNotEmpty) { + await tester.tap(cta.first); + await _pumpFrames(tester); + await _shot(tester, '11-einrichtung'); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await _pumpFrames(tester, 5); + } + }); +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 0a5fc2c..3f39e3f 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -13,4 +13,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 diff --git a/pubspec.lock b/pubspec.lock index 3f89ee3..720d556 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -172,6 +172,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.5" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_highlight: dependency: transitive description: @@ -219,6 +224,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" google_cloud: dependency: transitive description: @@ -291,6 +301,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -435,6 +450,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" protobuf: dependency: transitive description: @@ -544,6 +567,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -664,6 +695,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1a9cf75..edc2065 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^6.0.0 # Local-development override: while editing the swappable flow diff --git a/test/integration/hub_fixture.dart b/test/integration/hub_fixture.dart index c376c0a..4ca24de 100644 --- a/test/integration/hub_fixture.dart +++ b/test/integration/hub_fixture.dart @@ -71,13 +71,13 @@ class HubFixture { if (binary == null) { if (skipIfBinaryMissing) { markTestSkipped( - 'Hub fixture skipped: no `fai` binary found on PATH or in ' - '../chain_platform/target/release/fai. Build it with ' - '`cargo build --release` and re-run.', + 'Hub fixture skipped: no `chain` binary found via \$CHAIN_BIN, ' + 'PATH, or ../fai_chain/target/{release,debug}/chain. Build it ' + 'with `cargo build` in the platform repo and re-run.', ); return null; } - throw StateError('fai binary not found'); + throw StateError('chain binary not found'); } final tempDir = await Directory.systemTemp.createTemp('chain_studio_test_'); @@ -157,26 +157,36 @@ class HubFixture { } } - /// Look for `fai` first on PATH (production-ish), then in - /// the standard `target/release/` build output relative to - /// the platform checkout (developer flow). Returns null when - /// neither path resolves. + /// Resolved hub binary path, for tests that also drive the CLI + /// against the fixture's data dir (e.g. seeding projects). + static Future binaryPath() => _resolveBinary(); + + /// Look for the hub binary: $CHAIN_BIN override first, then + /// `chain` on PATH (production-ish), then the release/debug + /// build outputs relative to the platform checkout (developer + /// flow, repo dir `fai_chain`). Returns null when nothing + /// resolves. static Future _resolveBinary() async { + final fromEnv = Platform.environment['CHAIN_BIN']; + if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) { + return fromEnv; + } + final pathResult = await Process.run( Platform.isWindows ? 'where' : 'which', - ['fai'], + [Platform.isWindows ? 'chain.exe' : 'chain'], ); if (pathResult.exitCode == 0) { final s = (pathResult.stdout as String).trim(); if (s.isNotEmpty) return s.split('\n').first.trim(); } - final candidate = Platform.isWindows - ? '../chain_platform/target/release/fai.exe' - : '../chain_platform/target/release/fai'; - final f = File(candidate); - if (await f.exists()) { - return f.absolute.path; + final exe = Platform.isWindows ? 'chain.exe' : 'chain'; + for (final dir in ['release', 'debug']) { + final f = File('../fai_chain/target/$dir/$exe'); + if (await f.exists()) { + return f.absolute.path; + } } return null; } From ddac84ce8e1f16539c2136ea343b0dc0ccbdae10 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 13 Jul 2026 01:20:59 +0200 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20guided=20setup=20=E2=80=94=20person?= =?UTF-8?q?a=20re-audit=20fixes=20(grade-1=20round)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Regulated path finishes without a terminal: the signed-source state offers 'Add a signed source…' (stores dialog with pin-a-key) plus the per-module install buttons and a plain-language hint why pinning the publisher's key matters — instead of a hint with no affordance. - Apply warnings (e.g. the empty-trusted-publishers caveat) surface selectable in the done state instead of being swallowed. - Truthful preview: new lines state which machine is being set up (server/container targets configure THIS machine), that regulated profiles always get the hash-chained audit log (even with WORM off), and that the curated reading list is stored with the setup record. - Language pass: onboarding checklist in Sie-form + 'System-KI' (was du-form + 'System-AI'), 'Audit-Sperre' jargon replaced, answers file moved to a private per-dialog temp dir. - Screenshot harness: GUIDE_SHOTS_THEME=light for light-parity proof runs. Signed-off-by: flemming-it --- CHANGELOG.md | 21 +++++++ integration_test/guide_shots_test.dart | 13 +++- lib/l10n/app_de.arb | 28 ++++++--- lib/l10n/app_en.arb | 12 +++- lib/l10n/app_localizations.dart | 56 ++++++++++++++++- lib/l10n/app_localizations_de.dart | 53 +++++++++++++--- lib/l10n/app_localizations_en.dart | 37 ++++++++++- lib/widgets/guided_setup_dialog.dart | 86 ++++++++++++++++++++++++-- test/guided_setup_test.dart | 35 ++++++++++- 9 files changed, 308 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d36537..bf5b2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,27 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. silently skipped since the rename; it now resolves `$CHAIN_BIN`, `chain` on PATH, and `../fai_chain/target/{release,debug}/chain`. +### Added (guided setup — persona re-audit fixes, grade-1 round) + +- **Regulated path finishes without a terminal.** The post-apply + signed-source state now offers "Add a signed source…" (the stores + dialog with its pin-a-key field) plus the per-module install + buttons, instead of a hint with no affordance; a plain-language + trust hint explains why pinning the publisher's key matters. +- **Apply warnings surface.** Warning lines from `chain init --apply` + (e.g. the empty-trusted-publishers caveat) are shown selectable in + the done state instead of being swallowed on success. +- **Truthful preview.** New plan lines state which machine is being + set up (server/container targets configure THIS machine — said + explicitly), that regulated profiles get the hash-chained audit + log even when WORM is off, and that a curated reading list is + stored with the setup record. +- **Language pass.** Onboarding checklist switched to Sie-form and + "System-KI" (was du-form + "System-AI" next to the formal wizard); + "Audit-Sperre" jargon replaced with "Schreibschutz für das + Prüfprotokoll"; answers file now lives in a private per-dialog + temp dir instead of a fixed world-readable name. + ### Added (multi-project, stage ③ — sealed areas) - **Sealed-area connection switch.** The workspace switcher now lists diff --git a/integration_test/guide_shots_test.dart b/integration_test/guide_shots_test.dart index acb8f57..4d0ef7d 100644 --- a/integration_test/guide_shots_test.dart +++ b/integration_test/guide_shots_test.dart @@ -72,6 +72,13 @@ Future _pumpUntil( String get _outDir => Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots'; +/// Guide images ship dark; GUIDE_SHOTS_THEME=light exists for the +/// light-parity proof runs (release gate: both themes must hold). +ThemeModeValue get _theme => + Platform.environment['GUIDE_SHOTS_THEME'] == 'light' + ? ThemeModeValue.light + : ThemeModeValue.dark; + /// Rasterize the app's RepaintBoundary into `/.png`. Future _shot(WidgetTester tester, String name) async { // Two extra frames so ripples/route transitions settle visually. @@ -179,9 +186,9 @@ void main() { await tester.pumpWidget( RepaintBoundary( key: _shotKey, - child: const StudioApp( - initialThemeMode: ThemeModeValue.dark, - initialLocale: Locale('de'), + child: StudioApp( + initialThemeMode: _theme, + initialLocale: const Locale('de'), ), ), ); diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4933d00..5fff810 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -72,14 +72,14 @@ }, "welcomeChecklistHeader": "ERSTE SCHRITTE", "welcomeChecklistBody": "Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.", - "welcomeChecklistAi": "System-AI konfigurieren", - "welcomeChecklistAiHint": "Einstellungen → System-AI. Wird für die KI-Suche und Fehlererklärungen gebraucht.", + "welcomeChecklistAi": "System-KI konfigurieren", + "welcomeChecklistAiHint": "Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.", "welcomeChecklistMcp": "Öffentliche Capability-Quelle hinzufügen", "welcomeChecklistMcpHint": "Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.", "welcomeChecklistModule": "Ein Text-Modul installieren", - "welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie deiner Wahl installieren. Module sind sandboxed Ch∆In-Komponenten.", + "welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.", "welcomeChecklistFlow": "Einen gespeicherten Flow starten", - "welcomeChecklistFlowHint": "Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; auch ein eigener tut's.", + "welcomeChecklistFlowHint": "Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; ein eigener funktioniert genauso.", "welcomeChecklistAllDone": "Alle vier Schritte erledigt.", "welcomeChecklistDismiss": "Checkliste ausblenden", "welcomeChecklistRefresh": "Aktualisieren", @@ -886,16 +886,16 @@ "maintenanceResetConfirmTitle": "Hub zurücksetzen?", "maintenanceResetConfirmBody": "Stoppt jeden Daemon, verschiebt ~/.chain/ in ein Backup mit Zeitstempel und startet sauber neu. Das Backup ist vollständig wiederherstellbar. Fortfahren?", "maintenanceResetConfirmButton": "Zurücksetzen", - "welcomeChecklistAllSetTitle": "Du bist eingerichtet.", - "welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:", + "welcomeChecklistAllSetTitle": "Sie sind eingerichtet.", + "welcomeChecklistAllSetBody": "Drei Stränge, an denen Sie als Nächstes ziehen können:", "welcomeChecklistNextAuditTitle": "Audit-Log lesen", - "welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.", + "welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.", "welcomeChecklistNextAuditButton": "Protokoll öffnen", "welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten", "welcomeChecklistNextTodayBody": "tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.", "welcomeChecklistNextTodayButton": "Today-Doku öffnen", "welcomeChecklistNextModuleTitle": "Eigenes Modul bauen", - "welcomeChecklistNextModuleBody": "Mit `chain new module ` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.", + "welcomeChecklistNextModuleBody": "Mit `chain new module ` ein Rust+WASM-Gerüst erzeugen. Das module-sdk übernimmt die WIT-Anbindung; Sie schreiben nur ein typisiertes invoke.", "welcomeChecklistNextModuleButton": "Doku lesen", "auditGroupToday": "HEUTE", "auditGroupYesterday": "GESTERN", @@ -1704,7 +1704,7 @@ "setupReviewTitle": "Das wird Ch∆In einrichten", "setupChooseFreeText": "Oder beschreiben Sie einfach, was Sie vorhaben", "setupScenTryingOut": "Erst mal ausprobieren", - "setupScenTryingOutSub": "Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturen, ohne Audit-Sperre.", + "setupScenTryingOutSub": "Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturpflicht, ohne Schreibschutz für das Prüfprotokoll.", "setupScenTeamHub": "Ein Team-Server", "setupScenTeamHubSub": "Ein abgesicherter Hub für mehrere Personen — signierte Module, Ressourcen-Grenzen.", "setupScenRegulated": "Regulierter Produktivbetrieb", @@ -1761,6 +1761,16 @@ "setupAllowUnsigned": "Installation aus dem öffentlichen Store erlauben", "setupAllowUnsignedSub": "Lockert die Signaturpflicht bewusst. Gut zum Ausprobieren — für den regulierten Betrieb später wieder aktivieren.", "setupStartCta": "In 3 Fragen loslegen", + "setupPlanRunbookLocal": "Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.", + "setupPlanRunbookService": "Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.", + "setupPlanRunbookAirgap": "Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.", + "setupPlanRunbookContainer": "Vorgesehen für den Betrieb im Container — ein fertiges Compose-Beispiel wird mitgeliefert.", + "setupPlanAuditChain": "Jeder Schritt landet in einem lückenlosen, hash-verketteten Prüfprotokoll — Manipulation wird erkennbar.", + "setupPlanDocs": "Eine passende Leseliste ({n} Kapitel) wird mit dem Einrichtungs-Protokoll unter ~/.chain/ gespeichert.", + "@setupPlanDocs": {"placeholders": {"n": {"type": "int"}}}, + "setupTrustedPublishersHint": "Wichtig: Hinterlegen Sie beim Hinzufügen der Quelle den Schlüssel des Herausgebers („Signatur-Schlüssel anheften“) — sonst akzeptiert der Hub jede formal gültige Signatur, egal von wem.", + "setupAddSignedSource": "Signierte Quelle hinzufügen…", + "setupApplyNotes": "Hinweise aus der Einrichtung", "setupFreeTextHint": "z. B.: Eingehende Anträge vorprüfen und unvollständige markieren", "setupFreeTextSuggest": "Vorschlagen lassen", "setupFreeTextPrivacyLocal": "Ihre Beschreibung wird lokal auf diesem Rechner verarbeitet ({model}).", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d8c90fa..3fd3e84 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1743,7 +1743,7 @@ "setupReviewTitle": "This is what Ch∆In will set up", "setupChooseFreeText": "Or just describe what you want to do", "setupScenTryingOut": "Just trying it out", - "setupScenTryingOutSub": "A relaxed workspace on this machine — no signing, no audit locking.", + "setupScenTryingOutSub": "A straightforward workstation on this machine — no signature requirement, no write-protected audit log.", "setupScenTeamHub": "A shared team hub", "setupScenTeamHubSub": "A secured hub for several people — signed modules, resource limits.", "setupScenRegulated": "Regulated production", @@ -1800,6 +1800,16 @@ "setupAllowUnsigned": "Allow installing from the public store", "setupAllowUnsignedSub": "Deliberately relaxes the signature requirement. Fine for trying things out — re-enable it for regulated operation.", "setupStartCta": "Get started in 3 questions", + "setupPlanRunbookLocal": "This machine is being set up; the hub runs locally and starts on demand.", + "setupPlanRunbookService": "This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.", + "setupPlanRunbookAirgap": "Intended for an isolated server without internet — modules and updates arrive as offline bundles.", + "setupPlanRunbookContainer": "Intended for container operation — a ready-made compose example is included.", + "setupPlanAuditChain": "Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.", + "setupPlanDocs": "A matching reading list ({n} chapters) is stored with the setup record under ~/.chain/.", + "@setupPlanDocs": {"placeholders": {"n": {"type": "int"}}}, + "setupTrustedPublishersHint": "Important: when adding the source, pin the publisher's key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.", + "setupAddSignedSource": "Add a signed source…", + "setupApplyNotes": "Notes from the setup", "setupFreeTextHint": "e.g.: pre-screen incoming applications and flag incomplete ones", "setupFreeTextSuggest": "Suggest a setup", "setupFreeTextPrivacyLocal": "Your description is processed locally on this machine ({model}).", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7b44495..3a7f9ad 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5092,7 +5092,7 @@ abstract class AppLocalizations { /// No description provided for @setupScenTryingOutSub. /// /// In en, this message translates to: - /// **'A relaxed workspace on this machine — no signing, no audit locking.'** + /// **'A straightforward workstation on this machine — no signature requirement, no write-protected audit log.'** String get setupScenTryingOutSub; /// No description provided for @setupScenTeamHub. @@ -5395,6 +5395,60 @@ abstract class AppLocalizations { /// **'Get started in 3 questions'** String get setupStartCta; + /// No description provided for @setupPlanRunbookLocal. + /// + /// In en, this message translates to: + /// **'This machine is being set up; the hub runs locally and starts on demand.'** + String get setupPlanRunbookLocal; + + /// No description provided for @setupPlanRunbookService. + /// + /// In en, this message translates to: + /// **'This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.'** + String get setupPlanRunbookService; + + /// No description provided for @setupPlanRunbookAirgap. + /// + /// In en, this message translates to: + /// **'Intended for an isolated server without internet — modules and updates arrive as offline bundles.'** + String get setupPlanRunbookAirgap; + + /// No description provided for @setupPlanRunbookContainer. + /// + /// In en, this message translates to: + /// **'Intended for container operation — a ready-made compose example is included.'** + String get setupPlanRunbookContainer; + + /// No description provided for @setupPlanAuditChain. + /// + /// In en, this message translates to: + /// **'Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.'** + String get setupPlanAuditChain; + + /// No description provided for @setupPlanDocs. + /// + /// In en, this message translates to: + /// **'A matching reading list ({n} chapters) is stored with the setup record under ~/.chain/.'** + String setupPlanDocs(int n); + + /// No description provided for @setupTrustedPublishersHint. + /// + /// In en, this message translates to: + /// **'Important: when adding the source, pin the publisher\'s key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.'** + String get setupTrustedPublishersHint; + + /// No description provided for @setupAddSignedSource. + /// + /// In en, this message translates to: + /// **'Add a signed source…'** + String get setupAddSignedSource; + + /// No description provided for @setupApplyNotes. + /// + /// In en, this message translates to: + /// **'Notes from the setup'** + String get setupApplyNotes; + /// No description provided for @setupFreeTextHint. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ef59997..87f56cf 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -235,11 +235,11 @@ class AppLocalizationsDe extends AppLocalizations { 'Vier Schritte zu einem funktionierenden Hub. Live-Zustand — nach jeder Änderung aktualisieren.'; @override - String get welcomeChecklistAi => 'System-AI konfigurieren'; + String get welcomeChecklistAi => 'System-KI konfigurieren'; @override String get welcomeChecklistAiHint => - 'Einstellungen → System-AI. Wird für die KI-Suche und Fehlererklärungen gebraucht.'; + 'Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.'; @override String get welcomeChecklistMcp => 'Öffentliche Capability-Quelle hinzufügen'; @@ -253,14 +253,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomeChecklistModuleHint => - 'Im Store ein Modul aus der Kategorie deiner Wahl installieren. Module sind sandboxed Ch∆In-Komponenten.'; + 'Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.'; @override String get welcomeChecklistFlow => 'Einen gespeicherten Flow starten'; @override String get welcomeChecklistFlowHint => - 'Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; auch ein eigener tut\'s.'; + 'Flows → einen wählen → Starten. Studio bringt einige Demo-Flows mit; ein eigener funktioniert genauso.'; @override String get welcomeChecklistAllDone => 'Alle vier Schritte erledigt.'; @@ -1619,18 +1619,18 @@ class AppLocalizationsDe extends AppLocalizations { String get maintenanceResetConfirmButton => 'Zurücksetzen'; @override - String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.'; + String get welcomeChecklistAllSetTitle => 'Sie sind eingerichtet.'; @override String get welcomeChecklistAllSetBody => - 'Drei Stränge, an denen du als nächstes ziehen kannst:'; + 'Drei Stränge, an denen Sie als Nächstes ziehen können:'; @override String get welcomeChecklistNextAuditTitle => 'Audit-Log lesen'; @override String get welcomeChecklistNextAuditBody => - 'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Log. Im Protokoll-Tab siehst du, was dein Hub gemacht hat.'; + 'Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.'; @override String get welcomeChecklistNextAuditButton => 'Protokoll öffnen'; @@ -1650,7 +1650,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomeChecklistNextModuleBody => - 'Mit `chain new module ` ein Rust+WASM-Scaffold erzeugen. Das module-sdk übernimmt die WIT-Plumbing; du schreibst nur ein typisiertes invoke.'; + 'Mit `chain new module ` ein Rust+WASM-Gerüst erzeugen. Das module-sdk übernimmt die WIT-Anbindung; Sie schreiben nur ein typisiertes invoke.'; @override String get welcomeChecklistNextModuleButton => 'Doku lesen'; @@ -2991,7 +2991,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupScenTryingOutSub => - 'Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturen, ohne Audit-Sperre.'; + 'Ein unkomplizierter Arbeitsplatz auf diesem Rechner — ohne Signaturpflicht, ohne Schreibschutz für das Prüfprotokoll.'; @override String get setupScenTeamHub => 'Ein Team-Server'; @@ -3182,6 +3182,41 @@ class AppLocalizationsDe extends AppLocalizations { @override String get setupStartCta => 'In 3 Fragen loslegen'; + @override + String get setupPlanRunbookLocal => + 'Eingerichtet wird dieser Rechner; der Hub läuft lokal und wird bei Bedarf gestartet.'; + + @override + String get setupPlanRunbookService => + 'Eingerichtet wird dieser Rechner: Der Hub startet künftig automatisch beim Hochfahren (Hintergrund-Dienst). Für einen anderen Server führen Sie die Einrichtung dort aus.'; + + @override + String get setupPlanRunbookAirgap => + 'Vorgesehen für einen isolierten Server ohne Internet — Module und Updates kommen als Offline-Paket.'; + + @override + String get setupPlanRunbookContainer => + 'Vorgesehen für den Betrieb im Container — ein fertiges Compose-Beispiel wird mitgeliefert.'; + + @override + String get setupPlanAuditChain => + 'Jeder Schritt landet in einem lückenlosen, hash-verketteten Prüfprotokoll — Manipulation wird erkennbar.'; + + @override + String setupPlanDocs(int n) { + return 'Eine passende Leseliste ($n Kapitel) wird mit dem Einrichtungs-Protokoll unter ~/.chain/ gespeichert.'; + } + + @override + String get setupTrustedPublishersHint => + 'Wichtig: Hinterlegen Sie beim Hinzufügen der Quelle den Schlüssel des Herausgebers („Signatur-Schlüssel anheften“) — sonst akzeptiert der Hub jede formal gültige Signatur, egal von wem.'; + + @override + String get setupAddSignedSource => 'Signierte Quelle hinzufügen…'; + + @override + String get setupApplyNotes => 'Hinweise aus der Einrichtung'; + @override String get setupFreeTextHint => 'z. B.: Eingehende Anträge vorprüfen und unvollständige markieren'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f114fa4..9313e75 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2993,7 +2993,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupScenTryingOutSub => - 'A relaxed workspace on this machine — no signing, no audit locking.'; + 'A straightforward workstation on this machine — no signature requirement, no write-protected audit log.'; @override String get setupScenTeamHub => 'A shared team hub'; @@ -3179,6 +3179,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get setupStartCta => 'Get started in 3 questions'; + @override + String get setupPlanRunbookLocal => + 'This machine is being set up; the hub runs locally and starts on demand.'; + + @override + String get setupPlanRunbookService => + 'This machine is being set up: the hub will start automatically on boot (background service). For a different server, run the setup there.'; + + @override + String get setupPlanRunbookAirgap => + 'Intended for an isolated server without internet — modules and updates arrive as offline bundles.'; + + @override + String get setupPlanRunbookContainer => + 'Intended for container operation — a ready-made compose example is included.'; + + @override + String get setupPlanAuditChain => + 'Every step lands in a gapless, hash-chained audit log — tampering becomes detectable.'; + + @override + String setupPlanDocs(int n) { + return 'A matching reading list ($n chapters) is stored with the setup record under ~/.chain/.'; + } + + @override + String get setupTrustedPublishersHint => + 'Important: when adding the source, pin the publisher\'s key (\"pin signing key\") — otherwise the hub accepts any formally valid signature, no matter whose.'; + + @override + String get setupAddSignedSource => 'Add a signed source…'; + + @override + String get setupApplyNotes => 'Notes from the setup'; + @override String get setupFreeTextHint => 'e.g.: pre-screen incoming applications and flag incomplete ones'; diff --git a/lib/widgets/guided_setup_dialog.dart b/lib/widgets/guided_setup_dialog.dart index 9ca9ed7..2ea36d8 100644 --- a/lib/widgets/guided_setup_dialog.dart +++ b/lib/widgets/guided_setup_dialog.dart @@ -21,6 +21,7 @@ import '../data/system_actions.dart'; import '../l10n/app_localizations.dart'; import '../main.dart' show StudioShellState; import '../theme/tokens.dart'; +import 'chain_stores_dialog.dart'; /// Allowed wire values per answer — an AI suggestion is validated /// against these; anything else is rejected as a parse failure so a @@ -238,6 +239,11 @@ class _GuidedSetupDialogState extends State { @override void dispose() { _goalCtl.dispose(); + try { + _answersDir?.deleteSync(recursive: true); + } on FileSystemException { + // Best-effort cleanup; the OS temp reaper covers the rest. + } super.dispose(); } @@ -265,8 +271,16 @@ class _GuidedSetupDialogState extends State { 'data_must_stay_local: $_dataLocal\n' 'allow_unsigned_modules: $_allowUnsigned\n'; + /// Per-dialog private temp dir for the answers file — a fixed + /// name in the shared system temp would be world-readable and + /// racy on multi-user machines. Cleaned up in [dispose]. + Directory? _answersDir; + Future _writeAnswers() async { - final f = File('${Directory.systemTemp.path}/chain-setup-answers.yaml'); + _answersDir ??= await Directory.systemTemp.createTemp('chain-setup-'); + final f = File( + '${_answersDir!.path}${Platform.pathSeparator}answers.yaml', + ); await f.writeAsString(_answersYaml()); return f.path; } @@ -304,6 +318,11 @@ class _GuidedSetupDialogState extends State { } } + /// Warning lines the apply emitted on success (e.g. the empty + /// trusted_publishers caveat). Swallowing them made the wizard + /// claim more than the config delivers — show them instead. + String _applyWarnings = ''; + Future _apply() async { setState(() => _busy = true); final path = await _writeAnswers(); @@ -313,7 +332,15 @@ class _GuidedSetupDialogState extends State { if (!mounted) return; setState(() => _busy = false); if (r.ok) { - setState(() => _applied = true); + final warnings = r.stderr + .split('\n') + .where((line) => line.toLowerCase().contains('warn')) + .join('\n') + .trim(); + setState(() { + _applied = true; + _applyWarnings = warnings; + }); unawaited(_probeHub()); } else { showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr); @@ -736,13 +763,24 @@ class _GuidedSetupDialogState extends State { label: Text(l.setupNextHubStart), ), ), - // 2 — the plan's modules. + // 2 — the plan's modules. On a signature-strict plan the path + // stays clickable: add a signed source (the stores dialog with + // its pin-a-key field), then install — no terminal, no dead end. if (modules.isNotEmpty) ...[ if (airGapped) _hintRow(l.setupModulesOfflineHint) - else if (sigGate) - _hintRow(l.setupModulesSignedHint) else ...[ + if (sigGate) ...[ + _hintRow(l.setupModulesSignedHint), + _hintRow(l.setupTrustedPublishersHint), + _actionRow( + OutlinedButton.icon( + onPressed: () => ChainStoresDialog.show(context), + icon: const Icon(Icons.add_moderator_outlined, size: 18), + label: Text(l.setupAddSignedSource), + ), + ), + ], if (!_hubUp) _hintRow(l.setupStartHubFirst), for (final m in modules) _installed.contains(m) @@ -776,6 +814,20 @@ class _GuidedSetupDialogState extends State { label: Text(l.setupActionOpenFlow(flow)), ), ), + // Honesty: warnings the apply emitted (e.g. the empty + // trusted-publishers caveat), selectable so they can go + // verbatim into a compliance note. + if (_applyWarnings.isNotEmpty) ...[ + const SizedBox(height: ChainSpace.sm), + Text(l.setupApplyNotes, style: theme.textTheme.labelLarge), + const SizedBox(height: 4), + SelectableText( + _applyWarnings, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ]; } @@ -823,8 +875,9 @@ class _GuidedSetupDialogState extends State { /// Build the localized plain-language plan lines from the structured /// SetupPlan (never the CLI prose). List _planLines(AppLocalizations l, Map plan) { + final profile = plan['profile'] as String? ?? ''; final lines = []; - switch (plan['profile'] as String? ?? '') { + switch (profile) { case 'air-gapped': lines.add(l.setupPlanIntroAirgapped); case 'enterprise': @@ -832,13 +885,34 @@ class _GuidedSetupDialogState extends State { default: lines.add(l.setupPlanIntroDev); } + // Where this actually lands: the wizard always configures the + // machine Studio runs on — say so, especially when the operator + // picked a server/container target. + switch (plan['runbook'] as String? ?? '') { + case 'service': + lines.add(l.setupPlanRunbookService); + case 'air-gap-transfer': + lines.add(l.setupPlanRunbookAirgap); + case 'container': + lines.add(l.setupPlanRunbookContainer); + default: + lines.add(l.setupPlanRunbookLocal); + } if (plan['require_signatures'] == true) lines.add(l.setupPlanSignatures); + // The audit chain is always on for regulated profiles — the + // scenario card promises a tamper-evident log, so the preview + // states what is delivered (and the WORM line covers the rest). + if (profile == 'enterprise' || profile == 'air-gapped') { + lines.add(l.setupPlanAuditChain); + } if (plan['worm_audit'] == true) lines.add(l.setupPlanWorm); if (plan['approval_step'] == true) lines.add(l.setupPlanApproval); final modules = (plan['modules'] as List?)?.cast() ?? const []; if (modules.isNotEmpty) lines.add(l.setupPlanModules(modules.join(', '))); final flow = plan['starter_flow'] as String? ?? ''; if (flow.isNotEmpty) lines.add(l.setupPlanFlow(flow)); + final docs = (plan['curated_docs'] as List?)?.length ?? 0; + if (docs > 0) lines.add(l.setupPlanDocs(docs)); lines.add(l.setupPlanFileChanged('~/.chain/config.yaml')); return lines; } diff --git a/test/guided_setup_test.dart b/test/guided_setup_test.dart index 0faf348..7e3e6e6 100644 --- a/test/guided_setup_test.dart +++ b/test/guided_setup_test.dart @@ -163,17 +163,46 @@ void main() { ); testWidgets( - 'applied regulated (signed) state guides to the signed source instead of dead-end install buttons', + 'applied regulated (signed) state stays clickable — signed-source dialog, trust hint, install buttons', (tester) async { await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true)); await tester.tap(find.text('open')); await tester.pumpAndSettle(); + // The plain-language story + the pin-the-publisher trust hint. + expect(find.textContaining('signierten Quelle'), findsOneWidget); expect( - find.textContaining('signierten Quelle'), + find.textContaining('Schlüssel des Herausgebers'), + findsOneWidget, + ); + // No dead end: the stores dialog (with its pin-a-key field) is + // one click away, and the installs stay available for after + // the source is added. + expect(find.text('Signierte Quelle hinzufügen…'), findsOneWidget); + expect(find.text('text.extract installieren'), findsOneWidget); + expect(find.textContaining('chain store add'), findsNothing); + }, + ); + + testWidgets( + 'review preview names the machine being set up and the audit chain', + (tester) async { + await tester.pumpWidget(_seededHost(_regulatedPlan())); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Runbook honesty: a "home server" target still configures + // THIS machine — the preview must say so. + expect( + find.textContaining('Eingerichtet wird dieser Rechner'), + findsOneWidget, + ); + // The regulated promise is stated even when WORM is off: + // the hash-chained audit log line is always there. + expect( + find.textContaining('hash-verketteten Prüfprotokoll'), findsOneWidget, ); - expect(find.text('text.extract installieren'), findsNothing); }, ); From 7888b71f63241629e1893fa5fdc255e1816b470c Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 13 Jul 2026 01:26:33 +0200 Subject: [PATCH 6/6] fix(l10n): align the stores dialog with the setup trust hint (Sie-form, plain language) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regulated setup path sends the operator to the stores dialog to pin a publisher key — but the field was labelled 'Angepinnter Public-Key — PEM (optional)' with du-form developer prose (PEM, vendor key) at exactly the trust-critical moment. Field now reads 'Signatur-Schlüssel anheften (PEM, optional)' with a plain-language Sie-form explanation of what pinning does; the dialog intro and the onboarding checklist follow (MCP jargon explained in the sentence, developer-only follow-up cards labelled as such). Signed-off-by: flemming-it --- lib/l10n/app_de.arb | 12 ++++++------ lib/l10n/app_en.arb | 4 ++-- lib/l10n/app_localizations.dart | 4 ++-- lib/l10n/app_localizations_de.dart | 12 ++++++------ lib/l10n/app_localizations_en.dart | 5 ++--- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5fff810..c29d29b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -75,7 +75,7 @@ "welcomeChecklistAi": "System-KI konfigurieren", "welcomeChecklistAiHint": "Einstellungen → System-KI. Wird für die KI-Suche und Fehlererklärungen gebraucht.", "welcomeChecklistMcp": "Öffentliche Capability-Quelle hinzufügen", - "welcomeChecklistMcpHint": "Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.", + "welcomeChecklistMcpHint": "Verbindet einen öffentlichen Werkzeug-Katalog (MCP) — dessen Bausteine erscheinen dann im Store. Dort hinzufügen oder unter Einstellungen → MCP-Clients.", "welcomeChecklistModule": "Ein Text-Modul installieren", "welcomeChecklistModuleHint": "Im Store ein Modul aus der Kategorie Ihrer Wahl installieren. Module sind isolierte (sandboxed) Ch∆In-Komponenten.", "welcomeChecklistFlow": "Einen gespeicherten Flow starten", @@ -349,7 +349,7 @@ "storeReloadTooltip": "Aktualisieren", "storesManagerButton": "Store hinzufügen", "storesManagerTitle": "Module-Stores", - "storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.", + "storesManagerIntro": "Der Hub führt die Module aller Stores unten zu einem Index zusammen. Fügen Sie die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.", "storesManagerAddSection": "Store hinzufügen", "storesManagerNameLabel": "Name", "storesManagerUrlLabel": "Index-URL (https:// oder file://)", @@ -360,8 +360,8 @@ "storesManagerRemoveFailed": "Store konnte nicht entfernt werden", "storesManagerSuggested": "Vorgeschlagene Stores", "storesManagerAddShort": "Hinzufügen", - "storesManagerPinnedKeyLabel": "Angepinnter Public-Key — PEM (optional)", - "storesManagerPinnedKeyHint": "Signierte Module dieses Stores werden gegen diesen Key geprüft statt gegen den eingebauten Vendor-Key. Füge einen PEM-Public-Key ein.", + "storesManagerPinnedKeyLabel": "Signatur-Schlüssel anheften (PEM, optional)", + "storesManagerPinnedKeyHint": "Nur Module, die mit diesem Schlüssel signiert sind, werden aus dieser Quelle angenommen. Fügen Sie den öffentlichen Schlüssel (PEM) des Herausgebers ein — statt des eingebauten Herstellerschlüssels.", "storesManagerNotAvailable": "Noch nicht verfügbar", "storesManagerNotAvailableHint": "Der Anbieter hat diesen Store-Index noch nicht veröffentlicht. Sobald er das tut, ist er installierbar.", "storesSuggestedReclaimDesc": "Recl∆Im — Module für Rechts- und Pflichten-Reform-Mapping", @@ -892,10 +892,10 @@ "welcomeChecklistNextAuditBody": "Jede Installation, jeder Flow-Lauf, jede Freigabe landet im hash-verketteten Protokoll. Im Protokoll-Tab sehen Sie, was Ihr Hub gemacht hat.", "welcomeChecklistNextAuditButton": "Protokoll öffnen", "welcomeChecklistNextTodayTitle": "Daily-Today-Story einrichten", - "welcomeChecklistNextTodayBody": "tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.", + "welcomeChecklistNextTodayBody": "Für Fortgeschrittene: das Skript tools/today/propose.sh regelmäßig (Cron) laufen lassen — die Editorial-Karte im Store füllt sich dann mit kuratierten Stories.", "welcomeChecklistNextTodayButton": "Today-Doku öffnen", "welcomeChecklistNextModuleTitle": "Eigenes Modul bauen", - "welcomeChecklistNextModuleBody": "Mit `chain new module ` ein Rust+WASM-Gerüst erzeugen. Das module-sdk übernimmt die WIT-Anbindung; Sie schreiben nur ein typisiertes invoke.", + "welcomeChecklistNextModuleBody": "Für Entwickler: Mit `chain new module ` entsteht ein fertiges Modul-Gerüst (Rust+WASM). Das module-sdk übernimmt die technische Anbindung; Sie schreiben nur die eigentliche Logik.", "welcomeChecklistNextModuleButton": "Doku lesen", "auditGroupToday": "HEUTE", "auditGroupYesterday": "GESTERN", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3fd3e84..e56e517 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -378,8 +378,8 @@ "storesManagerRemoveFailed": "Could not remove store", "storesManagerSuggested": "Suggested stores", "storesManagerAddShort": "Add", - "storesManagerPinnedKeyLabel": "Pinned public key — PEM (optional)", - "storesManagerPinnedKeyHint": "Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.", + "storesManagerPinnedKeyLabel": "Pin a signing key (PEM, optional)", + "storesManagerPinnedKeyHint": "Only modules signed with this key are accepted from this source. Paste the publisher's public key (PEM) — used instead of the built-in vendor key.", "storesManagerNotAvailable": "Not available yet", "storesManagerNotAvailableHint": "The provider hasn't published this store index yet. It will become installable once they do.", "storesSuggestedReclaimDesc": "Recl∆Im — modules for law & duty reform-mapping", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3a7f9ad..f90f664 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1685,13 +1685,13 @@ abstract class AppLocalizations { /// No description provided for @storesManagerPinnedKeyLabel. /// /// In en, this message translates to: - /// **'Pinned public key — PEM (optional)'** + /// **'Pin a signing key (PEM, optional)'** String get storesManagerPinnedKeyLabel; /// No description provided for @storesManagerPinnedKeyHint. /// /// In en, this message translates to: - /// **'Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.'** + /// **'Only modules signed with this key are accepted from this source. Paste the publisher\'s public key (PEM) — used instead of the built-in vendor key.'** String get storesManagerPinnedKeyHint; /// No description provided for @storesManagerNotAvailable. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 87f56cf..79b055a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -246,7 +246,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomeChecklistMcpHint => - 'Im Store eine MCP-Quelle hinzufügen oder unter Einstellungen → MCP-Clients konfigurieren.'; + 'Verbindet einen öffentlichen Werkzeug-Katalog (MCP) — dessen Bausteine erscheinen dann im Store. Dort hinzufügen oder unter Einstellungen → MCP-Clients.'; @override String get welcomeChecklistModule => 'Ein Text-Modul installieren'; @@ -869,7 +869,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storesManagerIntro => - 'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Füge die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.'; + 'Der Hub führt die Module aller Stores unten zu einem Index zusammen. Fügen Sie die Index-URL eines Stores hinzu, damit seine Module hier installierbar werden.'; @override String get storesManagerAddSection => 'Store hinzufügen'; @@ -907,11 +907,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storesManagerPinnedKeyLabel => - 'Angepinnter Public-Key — PEM (optional)'; + 'Signatur-Schlüssel anheften (PEM, optional)'; @override String get storesManagerPinnedKeyHint => - 'Signierte Module dieses Stores werden gegen diesen Key geprüft statt gegen den eingebauten Vendor-Key. Füge einen PEM-Public-Key ein.'; + 'Nur Module, die mit diesem Schlüssel signiert sind, werden aus dieser Quelle angenommen. Fügen Sie den öffentlichen Schlüssel (PEM) des Herausgebers ein — statt des eingebauten Herstellerschlüssels.'; @override String get storesManagerNotAvailable => 'Noch nicht verfügbar'; @@ -1640,7 +1640,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomeChecklistNextTodayBody => - 'tools/today/propose.sh per Cron laufen lassen und die Editorial-Karte im Store füllt sich mit operator-kuratierten Stories.'; + 'Für Fortgeschrittene: das Skript tools/today/propose.sh regelmäßig (Cron) laufen lassen — die Editorial-Karte im Store füllt sich dann mit kuratierten Stories.'; @override String get welcomeChecklistNextTodayButton => 'Today-Doku öffnen'; @@ -1650,7 +1650,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get welcomeChecklistNextModuleBody => - 'Mit `chain new module ` ein Rust+WASM-Gerüst erzeugen. Das module-sdk übernimmt die WIT-Anbindung; Sie schreiben nur ein typisiertes invoke.'; + 'Für Entwickler: Mit `chain new module ` entsteht ein fertiges Modul-Gerüst (Rust+WASM). Das module-sdk übernimmt die technische Anbindung; Sie schreiben nur die eigentliche Logik.'; @override String get welcomeChecklistNextModuleButton => 'Doku lesen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9313e75..a1425a0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -925,12 +925,11 @@ class AppLocalizationsEn extends AppLocalizations { String get storesManagerAddShort => 'Add'; @override - String get storesManagerPinnedKeyLabel => - 'Pinned public key — PEM (optional)'; + String get storesManagerPinnedKeyLabel => 'Pin a signing key (PEM, optional)'; @override String get storesManagerPinnedKeyHint => - 'Signed modules from this store verify against this key instead of the built-in vendor key. Paste a PEM public key.'; + 'Only modules signed with this key are accepted from this source. Paste the publisher\'s public key (PEM) — used instead of the built-in vendor key.'; @override String get storesManagerNotAvailable => 'Not available yet';