diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index eb1c4112..babe1463 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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.", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index a8870065..e4d8b38f 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -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 diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 5e7c9d73..e2ad47b0 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/main.dart b/lib/main.dart index 4fe89f31..d37cc5f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -418,9 +418,16 @@ Future _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 repairStartupStorage(BuildContext context, StartupFailureRecord record, Object error) async { +Future 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 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 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 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 Function(BuildContext context, StartupFailureRecord record, Object error)? repair; final ThemeData? lightTheme; final ThemeData? darkTheme; @@ -564,6 +574,11 @@ class _StartupBootstrapState extends State> { 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 extends State> { Future _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 _discard(T value) async { @@ -683,6 +701,7 @@ class _StartupBootstrapState extends State> { : StartupFailureView( failure: failure, busy: _initializing || _repairing, + restartRequired: _restartRequired, onRetry: () => unawaited(_initialize()), onRepair: failure.repairable && widget.repair != null ? () => _repair(failure) : null, ), diff --git a/lib/services/startup_diagnostics.dart b/lib/services/startup_diagnostics.dart index bc2831e9..9749b7ac 100644 --- a/lib/services/startup_diagnostics.dart +++ b/lib/services/startup_diagnostics.dart @@ -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 diff --git a/lib/widgets/startup_failure_view.dart b/lib/widgets/startup_failure_view.dart index 253433ca..87d30420 100644 --- a/lib/widgets/startup_failure_view.dart +++ b/lib/widgets/startup_failure_view.dart @@ -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 Function({AppExitApplication? exitApplicationForTesting}) requestExit; + @override State createState() => _StartupFailureViewState(); } @@ -62,6 +84,16 @@ class _StartupFailureViewState extends State { showSuccessSnackBar(context, t.startup.detailsCopied); } + Future _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 _uploadDetails() async { setState(() => _uploading = true); try { @@ -104,7 +136,12 @@ class _StartupFailureViewState extends State { 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 { 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 { 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( diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index 7100ffa2..3affe868 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -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( + 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( + 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 diff --git a/test/widgets/startup_failure_view_test.dart b/test/widgets/startup_failure_view_test.dart new file mode 100644 index 00000000..de9582c7 --- /dev/null +++ b/test/widgets/startup_failure_view_test.dart @@ -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 _pumpView( + WidgetTester tester, { + required bool restartRequired, + Future 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); + }); +}