fix(setup): wizard errors copyable above the dialog, CLI skew explained, exec transparency
Some checks failed
Security / Security check (push) Failing after 2s

Field test of the setup wizard surfaced three trust breaks in one run:
an unexplained macOS Documents permission prompt, a perceived crash,
and an error message whose copy button could not be reached.

Root causes and fixes:
- chain init failures were shown as a SnackBar, which lands BEHIND the
  wizard's modal barrier: dimmed, clipped, copy unreachable — and the
  click aimed at it hit the barrier, dismissing the whole wizard with
  all answers (the perceived crash). Errors now open a modal dialog
  ABOVE the wizard via showChainErrorDialog with a copyable detail
  block, and the wizard is no longer barrier-dismissible.
- When the resolved chain binary is older than Studio and rejects
  --plan-json, the wizard now explains the version skew in plain
  language (binary path + update path) instead of leaking a raw clap
  usage error. A missing binary gets its own localized story.
- Step 3 announces which chain binary the preview will execute; when
  that binary physically lives (symlinks resolved) in a TCC-protected
  folder, the wizard pre-explains the macOS folder prompt.

Supporting changes: FriendlyError passes through friendlyError()
unchanged so call sites can ship precise localized stories through the
shared presentation; SystemActions gains resolvedChainBinary() plus
run/resolve test seams; ChainErrorBox hugs its content instead of
filling an unbounded dialog; the wizard's answers file is written
synchronously (the async dart:io variants never complete under the
widget-test fake-async zone).

Verified: flutter analyze clean, 53 tests green (6 new wizard error-
path tests incl. clipboard round-trip), plus a live GUI walk on macOS
in dark + light with a stale binary (skew dialog, copy verified via
clipboard) and with the real binary (TCC pre-explanation with the
resolved path, full plan preview).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-15 00:10:01 +02:00
parent 4ceb5bb567
commit c6da5025ce
11 changed files with 524 additions and 10 deletions

View file

@ -16,6 +16,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../data/error_presentation.dart';
import '../data/friendly_error.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
@ -118,8 +119,14 @@ class GuidedSetupDialog extends StatefulWidget {
static Future<void> show(BuildContext context) {
final shell = StudioShellState.of(context);
// Not barrier-dismissible: a stray click outside the dialog must
// not throw away a half-answered wizard (and error SnackBars used
// to lure exactly that click the operator aimed for the message
// below the barrier and lost everything). Leaving is explicit:
// Abbrechen on step 1, Zurück everywhere else.
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => GuidedSetupDialog(shell: shell),
);
}
@ -276,12 +283,15 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
/// racy on multi-user machines. Cleaned up in [dispose].
Directory? _answersDir;
Future<String> _writeAnswers() async {
_answersDir ??= await Directory.systemTemp.createTemp('chain-setup-');
String _writeAnswers() {
// Sync on purpose: the file is a handful of lines, and the async
// dart:io variants never complete inside the fake-async zone
// widget tests run in the wizard would hang there forever.
_answersDir ??= Directory.systemTemp.createTempSync('chain-setup-');
final f = File(
'${_answersDir!.path}${Platform.pathSeparator}answers.yaml',
);
await f.writeAsString(_answersYaml());
f.writeAsStringSync(_answersYaml());
return f.path;
}
@ -299,7 +309,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_busy = true;
_plan = null;
});
final path = await _writeAnswers();
final path = _writeAnswers();
final r = await SystemActions.chainInit(['--answers', path, '--plan-json']);
if (!mounted) return;
setState(() => _busy = false);
@ -311,13 +321,85 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
_step = _totalSteps; // review
});
} catch (_) {
showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr);
await _showCliError('chain init --plan-json', r.stdout, r.stderr);
}
} else {
showFaiProcessError(context, 'chain init --plan-json', r.stdout, r.stderr);
await _showCliError('chain init --plan-json', r.stdout, r.stderr);
}
}
/// Surface a failed `chain init` call as a modal dialog stacked
/// ABOVE this wizard. A SnackBar would land BEHIND the wizard's
/// modal barrier: visible but dimmed, its copy button unreachable
/// and the operator's attempt to click it hits the barrier instead.
/// Known failure shapes get a localized plain-language headline;
/// the verbatim CLI output stays copyable behind "Details".
Future<void> _showCliError(String source, String stdout, String stderr) {
final l = AppLocalizations.of(context)!;
final raw = [
stderr.trim(),
stdout.trim(),
].where((s) => s.isNotEmpty).join('\n\n');
final bin = SystemActions.resolvedChainBinary();
final FriendlyError friendly;
if (stderr.trim() == kFaiBinaryNotFound) {
// Sentinel, never shown verbatim (see SystemActions docs).
friendly = FriendlyError(
headline: l.setupNoBinary,
detail: '',
hint: l.setupNoBinaryHint,
);
} else if (raw.contains('unexpected argument')) {
// Version skew: this Studio speaks a newer `chain init` dialect
// than the binary it found (e.g. a stale build behind a channel
// symlink). Name the binary and the way out instead of leaking
// a raw clap usage error.
friendly = FriendlyError(
headline: l.setupCliTooOld,
detail: raw,
hint: l.setupCliTooOldHint(bin ?? 'chain'),
);
} else {
friendly = FriendlyError(
headline: source == 'chain init --apply'
? l.setupApplyFailed
: l.setupPreviewFailed,
detail: raw.isEmpty ? 'process exited non-zero (no output)' : raw,
hint: bin == null ? null : l.setupCliUsedBinary(bin),
);
}
return showChainErrorDialog(context, source, friendly);
}
/// Physical location of the resolved `chain` binary (symlinks
/// followed) the path that decides whether macOS shows a folder-
/// permission prompt when Studio executes it, regardless of how
/// harmless the symlink's own path looks.
String? _physicalChainBinary() {
final bin = SystemActions.resolvedChainBinary();
if (bin == null) return null;
try {
return File(bin).resolveSymbolicLinksSync();
} on FileSystemException {
return bin;
}
}
/// True when executing the resolved binary can trigger a macOS
/// folder-permission (TCC) prompt because it physically lives in a
/// protected folder. The wizard says so BEFORE the first exec
/// an unexplained "Studio wants access to Documents" prompt in the
/// middle of setup reads as a trust break.
bool _binaryNeedsFolderPermission() {
if (!Platform.isMacOS) return false;
final physical = _physicalChainBinary();
final home = Platform.environment['HOME'];
if (physical == null || home == null || home.isEmpty) return false;
return physical.startsWith('$home/Documents/') ||
physical.startsWith('$home/Desktop/') ||
physical.startsWith('$home/Downloads/');
}
/// 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.
@ -325,7 +407,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
Future<void> _apply() async {
setState(() => _busy = true);
final path = await _writeAnswers();
final path = _writeAnswers();
final r = await SystemActions.chainInit(
['--answers', path, '--apply', '--force'],
);
@ -343,7 +425,7 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
});
unawaited(_probeHub());
} else {
showFaiProcessError(context, 'chain init --apply', r.stdout, r.stderr);
await _showCliError('chain init --apply', r.stdout, r.stderr);
}
}
@ -527,6 +609,18 @@ class _GuidedSetupDialogState extends State<GuidedSetupDialog> {
value: _dataLocal,
onChanged: (v) => setState(() => _dataLocal = v),
),
// Exec transparency: the next click runs the `chain` binary.
// Say which one and warn when its physical location will
// make macOS ask for folder access, so the prompt (if any)
// arrives explained instead of as a trust break.
const SizedBox(height: ChainSpace.sm),
if (!SystemActions.chainBinaryExists())
_hintRow(l.setupNoBinaryStepHint)
else ...[
_hintRow(l.setupExecHint(SystemActions.resolvedChainBinary()!)),
if (_binaryNeedsFolderPermission())
_hintRow(l.setupExecHintTcc(_physicalChainBinary()!)),
],
],
// The free-text alternative lives on the first step: describe
// the goal, the system AI pre-selects the menu answers. Menu