fix(startup): stop offering Retry after a repair that needs a restart

A seed-and-restart repair writes the salvaged credentials straight to disk and
leaves this process's store closed, because the plugin still holds the bad
document in memory. repairCorruptStore says so plainly — "nothing may write a
preference before that restart … the caller keeps the app on the failure
screen precisely so nothing does" — but the caller did not. Clearing the
repairing flag re-enabled Retry, and pressing it reopened onto the stale map,
whose first write would flush it back over the seed and orphan every
ciphertext token in the database.

The repair hook returned a bare bool, which cannot express the difference
between "retry now" and "never retry in this process", so replace it with
StartupRepairResult. The restart case latches terminal state on the bootstrap,
withdraws Retry and Repair rather than grey them out — a disabled control
still invites another press — and says what to do instead, which nothing did:
repairNeedsRestart was a dialog title with no body anywhere. Desktop gets a
Quit button through the existing AppExitService seam; Copy and Upload stay
live everywhere, because a stuck user still needs the diagnostic out.

close #1732
This commit is contained in:
edde746
2026-08-01 06:59:20 +02:00
parent 3ae7aa554b
commit 9ecf8db90f
8 changed files with 304 additions and 36 deletions
+75 -17
View File
@@ -4,10 +4,12 @@ import 'package:material_symbols_icons/symbols.dart';
import '../focus/focusable_button.dart';
import '../i18n/strings.g.dart';
import '../services/app_exit_service.dart';
import '../services/log_upload_service.dart';
import '../services/startup_diagnostics.dart';
import '../utils/app_logger.dart';
import '../utils/dialogs.dart';
import '../utils/platform_detector.dart';
import '../utils/snackbar_helper.dart';
import 'app_icon.dart';
import 'dialog_action_button.dart';
@@ -18,6 +20,8 @@ const startupFailureDetailsKey = Key('startup-failure-details');
const startupFailureCopyKey = Key('startup-failure-copy');
const startupFailureUploadKey = Key('startup-failure-upload');
const startupFailureRepairKey = Key('startup-failure-repair');
const startupFailureRestartKey = Key('startup-failure-restart');
const startupFailureQuitKey = Key('startup-failure-quit');
/// Everything the startup gate can show when initialization fails.
///
@@ -31,7 +35,15 @@ const startupFailureRepairKey = Key('startup-failure-repair');
/// allowlist of already-redacted fields. Raw preference, database or file
/// contents never reach this widget.
class StartupFailureView extends StatefulWidget {
const StartupFailureView({super.key, required this.failure, required this.onRetry, this.onRepair, this.busy = false});
const StartupFailureView({
super.key,
required this.failure,
required this.onRetry,
this.onRepair,
this.busy = false,
this.restartRequired = false,
this.requestExit = AppExitService.requestExit,
});
final StartupFailureRecord failure;
final VoidCallback? onRetry;
@@ -42,6 +54,16 @@ class StartupFailureView extends StatefulWidget {
final bool busy;
/// The repair succeeded but the process must restart before the store can be
/// opened. Retry and Repair are withdrawn, not merely disabled: the plugin
/// still holds the bad document, and any preference write from this process
/// would overwrite the salvaged credentials (#1732).
final bool restartRequired;
/// Test seam. Quitting a widget-test binding is not something a test can
/// observe, so the exit call is injectable exactly like `deleteBackup`.
final Future<bool> Function({AppExitApplication? exitApplicationForTesting}) requestExit;
@override
State<StartupFailureView> createState() => _StartupFailureViewState();
}
@@ -62,6 +84,16 @@ class _StartupFailureViewState extends State<StartupFailureView> {
showSuccessSnackBar(context, t.startup.detailsCopied);
}
Future<void> _quit() async {
// Best effort: if the platform declines, the user still has the window
// controls, and the on-screen instruction already told them what to do.
try {
await widget.requestExit();
} catch (error, stackTrace) {
appLogger.w('Could not quit after a repair that needs a restart', error: error, stackTrace: stackTrace);
}
}
Future<void> _uploadDetails() async {
setState(() => _uploading = true);
try {
@@ -104,7 +136,12 @@ class _StartupFailureViewState extends State<StartupFailureView> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final failure = widget.failure;
final restartRequired = widget.restartRequired;
final enabled = !widget.busy && !_uploading;
// Retry and Repair are the two actions that can touch the store, so they
// are gone entirely once a restart is owed — not greyed out, because a
// disabled control still reads as "try me again later".
final canAct = enabled && !restartRequired;
final repair = widget.onRepair;
return Center(
@@ -116,11 +153,20 @@ class _StartupFailureViewState extends State<StartupFailureView> {
key: startupBootstrapFailureKey,
mainAxisSize: MainAxisSize.min,
children: [
const AppIcon(Symbols.error_rounded, size: 48),
AppIcon(restartRequired ? Symbols.restart_alt_rounded : Symbols.error_rounded, size: 48),
const SizedBox(height: 16),
Text(t.startup.failedTitle, style: theme.textTheme.titleLarge, textAlign: TextAlign.center),
Text(
restartRequired ? t.startup.repairNeedsRestart : t.startup.failedTitle,
style: theme.textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(t.startup.failedBody, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center),
Text(
restartRequired ? t.startup.restartRequiredBody : t.startup.failedBody,
key: restartRequired ? startupFailureRestartKey : null,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'${t.startup.phaseLabel}: ${failure.phaseId} · ${failure.errorType}',
@@ -135,25 +181,37 @@ class _StartupFailureViewState extends State<StartupFailureView> {
spacing: 12,
runSpacing: 12,
children: [
FocusableButton(
focusNode: _retryFocusNode,
autofocus: true,
onPressed: enabled ? widget.onRetry : null,
child: FilledButton(
key: startupBootstrapRetryKey,
onPressed: enabled ? widget.onRetry : null,
child: Text(t.common.retry),
),
),
if (repair != null)
if (!restartRequired)
FocusableButton(
onPressed: enabled ? () => repair() : null,
focusNode: _retryFocusNode,
autofocus: true,
onPressed: canAct ? widget.onRetry : null,
child: FilledButton(
key: startupBootstrapRetryKey,
onPressed: canAct ? widget.onRetry : null,
child: Text(t.common.retry),
),
),
if (!restartRequired && repair != null)
FocusableButton(
onPressed: canAct ? () => repair() : null,
child: FilledButton.tonal(
key: startupFailureRepairKey,
onPressed: enabled ? () => repair() : null,
onPressed: canAct ? () => repair() : null,
child: Text(t.startup.repairStorage),
),
),
if (restartRequired && PlatformDetector.isDesktopOS())
FocusableButton(
focusNode: _retryFocusNode,
autofocus: true,
onPressed: enabled ? _quit : null,
child: FilledButton(
key: startupFailureQuitKey,
onPressed: enabled ? _quit : null,
child: Text(t.startup.quitPlezy),
),
),
FocusableButton(
onPressed: enabled ? _copyDetails : null,
child: OutlinedButton(