fix(setup): wizard errors copyable above the dialog, CLI skew explained, exec transparency
Some checks failed
Security / Security check (push) Failing after 2s
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:
parent
4ceb5bb567
commit
c6da5025ce
11 changed files with 524 additions and 10 deletions
|
|
@ -97,6 +97,10 @@ class _ChainErrorBoxState extends State<ChainErrorBox> {
|
|||
border: Border.all(color: accent.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Column(
|
||||
// min, not max: inside an unbounded-height dialog the box
|
||||
// must hug its content — otherwise a one-line error renders
|
||||
// as a screen-tall empty red frame.
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue