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:
@@ -988,6 +988,8 @@
|
||||
"repairConfirm": "Repair",
|
||||
"repairSucceeded": "Storage repaired",
|
||||
"repairNeedsRestart": "Storage repaired — restart required",
|
||||
"restartRequiredBody": "Your data was repaired, but Plezy has to start fresh before it can use it. Close Plezy and open it again.",
|
||||
"quitPlezy": "Quit Plezy",
|
||||
"repairFailed": "Repair failed",
|
||||
"repairKeptSignIns": "Your servers and profiles are still signed in.",
|
||||
"repairLostSignIns": "The key protecting your saved sign-ins could not be recovered. You will have to sign in to every server and profile again.",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 32940 (1497 per locale)
|
||||
/// Strings: 32942 (1497 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -2893,6 +2893,12 @@ class Translations$startup$en {
|
||||
/// en: 'Storage repaired — restart required'
|
||||
String get repairNeedsRestart => 'Storage repaired — restart required';
|
||||
|
||||
/// en: 'Your data was repaired, but Plezy has to start fresh before it can use it. Close Plezy and open it again.'
|
||||
String get restartRequiredBody => 'Your data was repaired, but Plezy has to start fresh before it can use it. Close Plezy and open it again.';
|
||||
|
||||
/// en: 'Quit Plezy'
|
||||
String get quitPlezy => 'Quit Plezy';
|
||||
|
||||
/// en: 'Repair failed'
|
||||
String get repairFailed => 'Repair failed';
|
||||
|
||||
@@ -6934,6 +6940,8 @@ extension on Translations {
|
||||
'startup.repairConfirm' => 'Repair',
|
||||
'startup.repairSucceeded' => 'Storage repaired',
|
||||
'startup.repairNeedsRestart' => 'Storage repaired — restart required',
|
||||
'startup.restartRequiredBody' => 'Your data was repaired, but Plezy has to start fresh before it can use it. Close Plezy and open it again.',
|
||||
'startup.quitPlezy' => 'Quit Plezy',
|
||||
'startup.repairFailed' => 'Repair failed',
|
||||
'startup.repairKeptSignIns' => 'Your servers and profiles are still signed in.',
|
||||
'startup.repairLostSignIns' => 'The key protecting your saved sign-ins could not be recovered. You will have to sign in to every server and profile again.',
|
||||
@@ -7040,10 +7048,10 @@ extension on Translations {
|
||||
'explore.sourceMaterial.manga' => 'Manga',
|
||||
'explore.sourceMaterial.lightNovel' => 'Light novel',
|
||||
'explore.sourceMaterial.novel' => 'Novel',
|
||||
'explore.sourceMaterial.visualNovel' => 'Visual novel',
|
||||
'explore.sourceMaterial.game' => 'Game',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.sourceMaterial.visualNovel' => 'Visual novel',
|
||||
'explore.sourceMaterial.game' => 'Game',
|
||||
'explore.sourceMaterial.webComic' => 'Web comic',
|
||||
'explore.sourceMaterial.musicRelease' => 'Music',
|
||||
'explore.sourceMaterial.otherMedia' => 'Other',
|
||||
@@ -7554,10 +7562,10 @@ extension on Translations {
|
||||
'externalPlayer.playInExternalPlayer' => 'Play in External Player',
|
||||
'metadataEdit.editMetadata' => 'Edit...',
|
||||
'metadataEdit.screenTitle' => 'Edit Metadata',
|
||||
'metadataEdit.basicInfo' => 'Basic Info',
|
||||
'metadataEdit.artwork' => 'Artwork',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.basicInfo' => 'Basic Info',
|
||||
'metadataEdit.artwork' => 'Artwork',
|
||||
'metadataEdit.advancedSettings' => 'Advanced Settings',
|
||||
'metadataEdit.title' => 'Title',
|
||||
'metadataEdit.sortTitle' => 'Sort Title',
|
||||
|
||||
+31
-12
@@ -418,9 +418,16 @@ Future<SentryId> _captureStartupFailure(StartupFailureRecord record) {
|
||||
/// Default [StartupBootstrap.repair]: states the cost, runs the repair that
|
||||
/// matches the failure, then reports what was kept and what was lost.
|
||||
///
|
||||
/// Returns whether initialization should be retried.
|
||||
/// The result tells the bootstrap what it may do next; see
|
||||
/// [StartupRepairResult]. Never returns [StartupRepairResult.retry] for a
|
||||
/// repair that requires a restart — re-running initialization in that state
|
||||
/// would destroy the salvage.
|
||||
@visibleForTesting
|
||||
Future<bool> repairStartupStorage(BuildContext context, StartupFailureRecord record, Object error) async {
|
||||
Future<StartupRepairResult> repairStartupStorage(
|
||||
BuildContext context,
|
||||
StartupFailureRecord record,
|
||||
Object error,
|
||||
) async {
|
||||
final cause = StartupPhaseException.unwrap(error);
|
||||
final unreadableKey = cause is UnreadableSensitivePreferenceException ? cause.key : null;
|
||||
final reopenSafe = cause is! CorruptPreferenceStoreException || cause.reopenSafe;
|
||||
@@ -438,15 +445,16 @@ Future<bool> repairStartupStorage(BuildContext context, StartupFailureRecord rec
|
||||
confirmText: t.startup.repairConfirm,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return false;
|
||||
if (!confirmed || !context.mounted) return StartupRepairResult.none;
|
||||
|
||||
final outcome = unreadableKey != null
|
||||
? await BaseSharedPreferencesService.dropUnreadableCredential(unreadableKey)
|
||||
: await BaseSharedPreferencesService.repairCorruptStore(reopenSafe: reopenSafe);
|
||||
if (!context.mounted) return !outcome.requiresRestart;
|
||||
final result = outcome.requiresRestart ? StartupRepairResult.restart : StartupRepairResult.retry;
|
||||
if (!context.mounted) return result;
|
||||
|
||||
await showRepairOutcomeDialog(context, outcome);
|
||||
return context.mounted && !outcome.requiresRestart;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reports a completed repair, including the credential-bearing backup.
|
||||
@@ -543,8 +551,10 @@ class StartupBootstrap<T> extends StatefulWidget {
|
||||
final StartupFailureRecord Function(Object error, StackTrace stackTrace) describeFailure;
|
||||
|
||||
/// Offers the user a consented repair for a recoverable failure and reports
|
||||
/// whether one ran. Returning true re-runs [initialize].
|
||||
final Future<bool> Function(BuildContext context, StartupFailureRecord record, Object error)? repair;
|
||||
/// what the gate may do next. [StartupRepairResult.retry] re-runs
|
||||
/// [initialize]; [StartupRepairResult.restart] parks the app on the failure
|
||||
/// screen, because nothing may touch preferences until the process restarts.
|
||||
final Future<StartupRepairResult> Function(BuildContext context, StartupFailureRecord record, Object error)? repair;
|
||||
|
||||
final ThemeData? lightTheme;
|
||||
final ThemeData? darkTheme;
|
||||
@@ -564,6 +574,11 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
bool _completed = false;
|
||||
bool _initializing = false;
|
||||
bool _repairing = false;
|
||||
|
||||
/// Terminal: the store was repaired but the plugin still holds the bad
|
||||
/// document, so this process can never open it. Latched, never cleared —
|
||||
/// re-running the gate from here would flush the stale map over the seed.
|
||||
bool _restartRequired = false;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
@@ -628,18 +643,21 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
Future<void> _repair(StartupFailureRecord failure) async {
|
||||
final repair = widget.repair;
|
||||
final error = _failureError;
|
||||
if (repair == null || error == null || _repairing) return;
|
||||
if (repair == null || error == null || _repairing || _restartRequired) return;
|
||||
setState(() => _repairing = true);
|
||||
var repaired = false;
|
||||
var result = StartupRepairResult.none;
|
||||
try {
|
||||
repaired = await repair(context, failure, error);
|
||||
result = await repair(context, failure, error);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.e('Startup storage repair failed', error: error, stackTrace: stackTrace);
|
||||
if (mounted) showErrorSnackBar(context, t.startup.repairFailed);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _repairing = false);
|
||||
if (repaired) unawaited(_initialize());
|
||||
setState(() {
|
||||
_repairing = false;
|
||||
if (result == StartupRepairResult.restart) _restartRequired = true;
|
||||
});
|
||||
if (result == StartupRepairResult.retry) unawaited(_initialize());
|
||||
}
|
||||
|
||||
Future<void> _discard(T value) async {
|
||||
@@ -683,6 +701,7 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
: StartupFailureView(
|
||||
failure: failure,
|
||||
busy: _initializing || _repairing,
|
||||
restartRequired: _restartRequired,
|
||||
onRetry: () => unawaited(_initialize()),
|
||||
onRepair: failure.repairable && widget.repair != null ? () => _repair(failure) : null,
|
||||
),
|
||||
|
||||
@@ -9,6 +9,25 @@ import 'package:path_provider/path_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
|
||||
/// What the consented storage repair leaves the startup gate able to do.
|
||||
///
|
||||
/// A bare "did it run" boolean cannot express the restart case, and getting it
|
||||
/// wrong is destructive: after a seed-and-restart repair the plugin still holds
|
||||
/// the bad document in memory, so re-running initialization would flush that
|
||||
/// stale map back over the freshly seeded store and orphan every ciphertext
|
||||
/// token in the database (#1732).
|
||||
enum StartupRepairResult {
|
||||
/// Nothing was repaired — the user declined, or there was nothing to do.
|
||||
none,
|
||||
|
||||
/// The store was repaired and initialization can be retried in this process.
|
||||
retry,
|
||||
|
||||
/// The store was repaired but the process must restart before it is usable.
|
||||
/// Initialization must not run again, and nothing may write a preference.
|
||||
restart,
|
||||
}
|
||||
|
||||
/// Named steps of the startup gate.
|
||||
///
|
||||
/// The gate used to report a bare `error.runtimeType` with no indication of
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -163,7 +163,7 @@ void main() {
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
repair: (_, _, _) async {
|
||||
repairCalls++;
|
||||
return true;
|
||||
return StartupRepairResult.retry;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -188,7 +188,7 @@ void main() {
|
||||
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
|
||||
},
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
repair: (_, _, _) async => false,
|
||||
repair: (_, _, _) async => StartupRepairResult.none,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
@@ -201,6 +201,57 @@ void main() {
|
||||
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a repair that needs a restart parks the app instead of retrying', (tester) async {
|
||||
// The repair seeds the salvaged credentials straight to disk and leaves
|
||||
// this process's store closed, because the plugin still holds the bad
|
||||
// document. Re-running the gate would reopen onto that stale map and the
|
||||
// first write would flush it over the seed, destroying the vault key and
|
||||
// orphaning every ciphertext token in the database (#1732).
|
||||
var attempts = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async {
|
||||
attempts++;
|
||||
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
|
||||
},
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
repair: (_, _, _) async => StartupRepairResult.restart,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(startupFailureRepairKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(attempts, 1);
|
||||
// Withdrawn, not disabled: both are the actions that would touch the store.
|
||||
expect(find.byKey(startupBootstrapRetryKey), findsNothing);
|
||||
expect(find.byKey(startupFailureRepairKey), findsNothing);
|
||||
// And the user is actually told what to do about it.
|
||||
expect(find.byKey(startupFailureRestartKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the restart state survives and still allows uploading the diagnostic', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
repair: (_, _, _) async => StartupRepairResult.restart,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(startupFailureRepairKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
// Copy and Upload stay live — the whole point of the failure screen is
|
||||
// that a stuck user can still get the diagnostic out.
|
||||
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
|
||||
expect(find.byKey(startupFailureUploadKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('persists the failure so it can be reported once the reporter is up', (tester) async {
|
||||
// Not reported inline: the earliest gate phases run before crash
|
||||
// reporting exists, so an inline capture would reach a no-op hub and be
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/services/app_exit_service.dart';
|
||||
import 'package:plezy/services/startup_diagnostics.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/startup_failure_view.dart';
|
||||
|
||||
StartupFailureRecord _record() => StartupFailureRecord(
|
||||
phase: StartupPhase.preferences,
|
||||
errorType: 'CorruptPreferenceStoreException',
|
||||
message: 'the preference store could not be parsed',
|
||||
stackTrace: null,
|
||||
timestamp: DateTime.utc(2026),
|
||||
appVersion: '2.11.1',
|
||||
platform: 'windows',
|
||||
repairable: true,
|
||||
);
|
||||
|
||||
Future<void> _pumpView(
|
||||
WidgetTester tester, {
|
||||
required bool restartRequired,
|
||||
Future<bool> Function()? onExitRequested,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: StartupFailureView(
|
||||
failure: _record(),
|
||||
restartRequired: restartRequired,
|
||||
onRetry: () {},
|
||||
onRepair: () async {},
|
||||
requestExit: ({AppExitApplication? exitApplicationForTesting}) async =>
|
||||
onExitRequested == null ? false : await onExitRequested(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
tearDown(() => PlatformDetector.debugSetIsDesktopOSOverride(null));
|
||||
testWidgets('the ordinary failure offers retry and repair', (tester) async {
|
||||
await _pumpView(tester, restartRequired: false);
|
||||
|
||||
expect(find.byKey(startupBootstrapRetryKey), findsOneWidget);
|
||||
expect(find.byKey(startupFailureRepairKey), findsOneWidget);
|
||||
expect(find.byKey(startupFailureRestartKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('a pending restart withdraws every action that would touch the store', (tester) async {
|
||||
await _pumpView(tester, restartRequired: true);
|
||||
|
||||
// Withdrawn rather than disabled: a greyed-out Retry still invites another
|
||||
// press, and pressing it would flush the plugin's stale map over the
|
||||
// freshly seeded credentials (#1732).
|
||||
expect(find.byKey(startupBootstrapRetryKey), findsNothing);
|
||||
expect(find.byKey(startupFailureRepairKey), findsNothing);
|
||||
// The diagnostic actions stay, because a stuck user still needs them out.
|
||||
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
|
||||
expect(find.byKey(startupFailureUploadKey), findsOneWidget);
|
||||
expect(find.text(t.startup.restartRequiredBody), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the desktop quit button asks the platform to exit', (tester) async {
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(true);
|
||||
var exitRequests = 0;
|
||||
|
||||
await _pumpView(
|
||||
tester,
|
||||
restartRequired: true,
|
||||
onExitRequested: () async {
|
||||
exitRequests++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(find.byKey(startupFailureQuitKey), findsOneWidget);
|
||||
await tester.tap(find.byKey(startupFailureQuitKey));
|
||||
await tester.pump();
|
||||
|
||||
expect(exitRequests, 1);
|
||||
});
|
||||
|
||||
testWidgets('a platform that refuses to quit does not break the screen', (tester) async {
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(true);
|
||||
|
||||
await _pumpView(tester, restartRequired: true, onExitRequested: () async => throw StateError('no'));
|
||||
|
||||
await tester.tap(find.byKey(startupFailureQuitKey));
|
||||
await tester.pump();
|
||||
|
||||
// The instruction is still on screen; the window controls remain the
|
||||
// user's fallback.
|
||||
expect(find.text(t.startup.restartRequiredBody), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a non-desktop host gets the instruction without a quit button', (tester) async {
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(false);
|
||||
|
||||
await _pumpView(tester, restartRequired: true);
|
||||
|
||||
expect(find.byKey(startupFailureQuitKey), findsNothing);
|
||||
expect(find.text(t.startup.restartRequiredBody), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user