fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of 10336 bytes, every one of them zero, and reported each as "still failing". The gate classified it correctly every time and the consented repair would have cleared it in-process, but nothing on the failure screen said so: Retry was first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside it as a tonal afterthought. Retry re-reads the same document, so for a corrupt-store failure it is an action that cannot succeed however many times it is pressed — and it was the one the screen recommended. Repair now takes the primary styling, the focus node and first position whenever it is offered, and the body text says plainly that retrying will not help. Retry keeps its place for every other failure, where a locked database or a denied directory really can change between attempts. The consent dialog was also promising an outcome it could not always deliver. Servers and profiles survive a repair only because their tokens are ciphertext in the database and the key that decrypts them lives in the store, so a store the key cannot be read out of signs the user out of everything — exactly the all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without touching it, and the dialog now names the real cost from that. The retained copy is labelled as holding credentials unless the bytes prove otherwise: what the salvage recovered says nothing about what the file still contains, because a store truncated mid-value keeps most of a vault key in plaintext while the salvage pattern — which needs the value's closing quote — matches nothing at all. Only an all-zero file drops the warning, so the one case that cries wolf is the one that provably holds no secret. `describe()` finally carries whether a repair was on offer. That line is the difference between a report a maintainer can act on and two days of guessing whether the button was even on screen. close #1732
This commit is contained in:
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "Plezy could not start",
|
||||
"failedBody": "Something went wrong during startup. The details below identify what failed.",
|
||||
"failedBodyRepairable": "Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.",
|
||||
"phaseLabel": "Step",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "Repair stored data?",
|
||||
"repairBodyCommon": "Plezy's settings file is damaged and cannot be read. Repairing resets every setting to its default.",
|
||||
"repairBodyOneCredential": "One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.",
|
||||
"repairBodySessionsAtRisk": "Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.",
|
||||
"repairBodySignInsKept": "Your servers and profiles should stay signed in.",
|
||||
"repairBodySignInsLost": "The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.",
|
||||
"repairBodySessionsUncertain": "Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.",
|
||||
"repairConfirm": "Repair",
|
||||
"repairSucceeded": "Storage repaired",
|
||||
"repairNeedsRestart": "Storage repaired — restart required",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -986,6 +986,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -996,7 +997,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -986,6 +986,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -996,7 +997,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -989,6 +989,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -999,7 +1000,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -989,6 +989,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -999,7 +1000,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 32942 (1497 per locale)
|
||||
/// Strings: 32945 (1497 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -2853,6 +2853,9 @@ class Translations$startup$en {
|
||||
/// en: 'Something went wrong during startup. The details below identify what failed.'
|
||||
String get failedBody => 'Something went wrong during startup. The details below identify what failed.';
|
||||
|
||||
/// en: 'Plezy's saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.'
|
||||
String get failedBodyRepairable => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.';
|
||||
|
||||
/// en: 'Step'
|
||||
String get phaseLabel => 'Step';
|
||||
|
||||
@@ -2883,8 +2886,14 @@ class Translations$startup$en {
|
||||
/// en: 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.'
|
||||
String get repairBodyOneCredential => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.';
|
||||
|
||||
/// en: 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.'
|
||||
String get repairBodySessionsAtRisk => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.';
|
||||
/// en: 'Your servers and profiles should stay signed in.'
|
||||
String get repairBodySignInsKept => 'Your servers and profiles should stay signed in.';
|
||||
|
||||
/// en: 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.'
|
||||
String get repairBodySignInsLost => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.';
|
||||
|
||||
/// en: 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.'
|
||||
String get repairBodySessionsUncertain => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.';
|
||||
|
||||
/// en: 'Repair'
|
||||
String get repairConfirm => 'Repair';
|
||||
@@ -6937,6 +6946,7 @@ extension on Translations {
|
||||
'logs.uploadLogs' => 'Upload Logs',
|
||||
'startup.failedTitle' => 'Plezy could not start',
|
||||
'startup.failedBody' => 'Something went wrong during startup. The details below identify what failed.',
|
||||
'startup.failedBodyRepairable' => 'Plezy\'s saved settings file is damaged and has to be rebuilt before Plezy can start. Retrying will not help — choose Repair storage.',
|
||||
'startup.phaseLabel' => 'Step',
|
||||
'startup.showDetails' => 'Show details',
|
||||
'startup.hideDetails' => 'Hide details',
|
||||
@@ -6947,7 +6957,9 @@ extension on Translations {
|
||||
'startup.repairTitle' => 'Repair stored data?',
|
||||
'startup.repairBodyCommon' => 'Plezy\'s settings file is damaged and cannot be read. Repairing resets every setting to its default.',
|
||||
'startup.repairBodyOneCredential' => 'One saved sign-in is damaged and cannot be read. Repairing removes just that one; your other settings are left alone.',
|
||||
'startup.repairBodySessionsAtRisk' => 'Servers and profiles normally stay signed in, but trackers (MAL, AniList, Simkl, Trakt) and Seerr may need to be reconnected. Plezy will tell you exactly what it kept.',
|
||||
'startup.repairBodySignInsKept' => 'Your servers and profiles should stay signed in.',
|
||||
'startup.repairBodySignInsLost' => 'The key protecting your saved sign-ins cannot be recovered from this file, so you will have to sign in to every server and profile again. Nothing on your media server is affected.',
|
||||
'startup.repairBodySessionsUncertain' => 'Trackers (MAL, AniList, Simkl, Trakt) and Seerr are stored separately and may or may not survive. Plezy will tell you exactly what it kept.',
|
||||
'startup.repairConfirm' => 'Repair',
|
||||
'startup.repairSucceeded' => 'Storage repaired',
|
||||
'startup.repairNeedsRestart' => 'Storage repaired — restart required',
|
||||
@@ -7045,11 +7057,11 @@ extension on Translations {
|
||||
'explore.season.winter' => 'Winter',
|
||||
'explore.season.spring' => 'Spring',
|
||||
'explore.season.summer' => 'Summer',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.season.fall' => 'Fall',
|
||||
'explore.season.withYear' => ({required Object season, required Object year}) => '${season} ${year}',
|
||||
'explore.format.tv' => 'TV',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.format.tvShort' => 'TV Short',
|
||||
'explore.format.movie' => 'Movie',
|
||||
'explore.format.special' => 'Special',
|
||||
@@ -7559,11 +7571,11 @@ extension on Translations {
|
||||
'externalPlayer.off' => 'Off',
|
||||
'externalPlayer.launchFailed' => 'Failed to open external player',
|
||||
'externalPlayer.appNotInstalled' => ({required Object name}) => '${name} is not installed',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'externalPlayer.playInExternalPlayer' => 'Play in External Player',
|
||||
'metadataEdit.editMetadata' => 'Edit...',
|
||||
'metadataEdit.screenTitle' => 'Edit Metadata',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.basicInfo' => 'Basic Info',
|
||||
'metadataEdit.artwork' => 'Artwork',
|
||||
'metadataEdit.advancedSettings' => 'Advanced Settings',
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -987,6 +987,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -997,7 +998,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -986,6 +986,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -996,7 +997,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
@@ -986,6 +986,7 @@
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"failedBodyRepairable": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
@@ -996,7 +997,9 @@
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairBodySignInsKept": "",
|
||||
"repairBodySignInsLost": "",
|
||||
"repairBodySessionsUncertain": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
|
||||
+50
-12
@@ -85,6 +85,7 @@ import 'utils/android_exit_diagnostics.dart';
|
||||
import 'utils/storage_failure.dart';
|
||||
import 'services/base_shared_preferences_service.dart';
|
||||
import 'services/prefs_recovery.dart';
|
||||
import 'services/sensitive_prefs.dart';
|
||||
import 'services/startup_diagnostics.dart';
|
||||
import 'utils/dialogs.dart';
|
||||
import 'widgets/dialog_action_button.dart';
|
||||
@@ -432,16 +433,21 @@ Future<StartupRepairResult> repairStartupStorage(
|
||||
final unreadableKey = cause is UnreadableSensitivePreferenceException ? cause.key : null;
|
||||
final reopenSafe = cause is! CorruptPreferenceStoreException || cause.reopenSafe;
|
||||
|
||||
// Name the cost before touching anything. A salvaged vault key keeps servers
|
||||
// and profiles signed in because those tokens are ciphertext in the
|
||||
// database; tracker and Seerr sessions are plaintext preference entries and
|
||||
// can still be lost, so neither branch promises more than it can deliver.
|
||||
// Name the real cost before touching anything, not the usual one. Servers
|
||||
// and profiles survive a repair only because their tokens are ciphertext in
|
||||
// the database and the key that decrypts them lives in the store — so a
|
||||
// store the key cannot be read out of signs the user out of everything. An
|
||||
// all-zero file is exactly that case (#1732), and telling them their
|
||||
// sign-ins are safe would be a promise the outcome dialog then contradicts.
|
||||
final signInsSurvive = unreadableKey != null
|
||||
? unreadableKey != credentialVaultKeyPref
|
||||
: (await PrefsRecovery.previewSalvage()).vaultKey != null;
|
||||
if (!context.mounted) return StartupRepairResult.none;
|
||||
|
||||
final confirmed = await showConfirmDialog(
|
||||
context,
|
||||
title: t.startup.repairTitle,
|
||||
message: unreadableKey != null
|
||||
? '${t.startup.repairBodyOneCredential}\n\n${t.startup.repairBodySessionsAtRisk}'
|
||||
: '${t.startup.repairBodyCommon}\n\n${t.startup.repairBodySessionsAtRisk}',
|
||||
message: repairConsentMessage(oneCredential: unreadableKey != null, signInsSurvive: signInsSurvive),
|
||||
confirmText: t.startup.repairConfirm,
|
||||
isDestructive: true,
|
||||
);
|
||||
@@ -457,11 +463,41 @@ Future<StartupRepairResult> repairStartupStorage(
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reports a completed repair, including the credential-bearing backup.
|
||||
/// The consent dialog's body: what is being repaired, then what it costs.
|
||||
///
|
||||
/// Separated from [repairStartupStorage] because the two halves fail
|
||||
/// differently. Deciding whether the sign-ins survive is I/O against a
|
||||
/// damaged file; deciding what to *tell* the user about it is a promise, and
|
||||
/// #1732 is what happens when the promise is written once, optimistically,
|
||||
/// for a case that does not always hold.
|
||||
///
|
||||
/// [signInsSurvive] covers servers and profiles only. Their tokens are
|
||||
/// ciphertext in the database, so they live or die with the vault key —
|
||||
/// which is exactly why that one value decides this sentence.
|
||||
///
|
||||
/// Tracker and Seerr sessions are plaintext preference entries, salvaged and
|
||||
/// reseeded one by one and untouched entirely by the single-credential
|
||||
/// repair. They therefore do not follow the vault key in either direction,
|
||||
/// and get one cautious sentence in both branches rather than a promise
|
||||
/// borrowed from a value that does not govern them.
|
||||
@visibleForTesting
|
||||
String repairConsentMessage({required bool oneCredential, required bool signInsSurvive}) => [
|
||||
oneCredential ? t.startup.repairBodyOneCredential : t.startup.repairBodyCommon,
|
||||
signInsSurvive ? t.startup.repairBodySignInsKept : t.startup.repairBodySignInsLost,
|
||||
t.startup.repairBodySessionsUncertain,
|
||||
].join('\n\n');
|
||||
|
||||
/// Reports a completed repair, including the retained copy of the damaged
|
||||
/// store.
|
||||
///
|
||||
/// The backup path is shown so the user can find and delete it, never so it
|
||||
/// can be attached to a report: it holds the credential-vault key, tracker
|
||||
/// refresh tokens and Seerr cookies in plaintext.
|
||||
/// can be attached to a report: it can hold the credential-vault key, tracker
|
||||
/// refresh tokens and Seerr cookies in plaintext. The warning is suppressed
|
||||
/// only when the quarantined *bytes* prove there is nothing in them — an
|
||||
/// all-zero file (#1732) — never on the strength of what the salvage managed
|
||||
/// to recover, which says nothing about what is still in the file. A warning
|
||||
/// on a file of zeros is the one that teaches users to ignore the warning
|
||||
/// that matters.
|
||||
@visibleForTesting
|
||||
Future<void> showRepairOutcomeDialog(
|
||||
BuildContext context,
|
||||
@@ -492,8 +528,10 @@ Future<void> showRepairOutcomeDialog(
|
||||
Text(t.startup.backupTitle, style: Theme.of(dialogContext).textTheme.titleSmall),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(backupPath, style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)),
|
||||
if (outcome.backupHoldsCredentials) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(t.startup.backupWarning, style: TextStyle(color: Theme.of(dialogContext).colorScheme.error)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
DialogActionButton(
|
||||
label: t.startup.deleteBackup,
|
||||
|
||||
@@ -139,7 +139,12 @@ abstract class BaseSharedPreferencesService {
|
||||
/// unreadable value — quarantining that one would reopen onto the stale
|
||||
/// in-memory map and write the bad value straight back.
|
||||
static Future<PrefsRepairOutcome> repairCorruptStore({bool reopenSafe = true}) async {
|
||||
final (:salvaged, :backupPath) = await PrefsRecovery.quarantine();
|
||||
final (:salvaged, :backupPath, :shape) = await PrefsRecovery.quarantine();
|
||||
// Conservative by construction: the warning is dropped only for bytes
|
||||
// proven to contain nothing. What the salvage recovered cannot stand in
|
||||
// for that — a store truncated mid-value matches no entry at all while
|
||||
// still holding most of a vault key in plaintext.
|
||||
final backupHoldsCredentials = !(shape?.allZero ?? false);
|
||||
|
||||
_resetGeneration++;
|
||||
_initializations.clear();
|
||||
@@ -158,6 +163,7 @@ abstract class BaseSharedPreferencesService {
|
||||
appLogger.w('Preference store quarantined; a restart is required before it can be reopened');
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
backupHoldsCredentials: backupHoldsCredentials,
|
||||
vaultKeySalvaged: seeded && salvaged.vaultKey != null,
|
||||
sessionsSalvaged: seeded ? salvaged.sessions.length : 0,
|
||||
sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length,
|
||||
@@ -190,6 +196,7 @@ abstract class BaseSharedPreferencesService {
|
||||
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
backupHoldsCredentials: backupHoldsCredentials,
|
||||
vaultKeySalvaged: salvaged.vaultKey != null,
|
||||
sessionsSalvaged: salvaged.sessions.length,
|
||||
sessionsLost: salvaged.losses,
|
||||
|
||||
@@ -142,14 +142,26 @@ class PrefsRepairOutcome {
|
||||
required this.vaultKeySalvaged,
|
||||
required this.sessionsSalvaged,
|
||||
required this.sessionsLost,
|
||||
this.backupHoldsCredentials = true,
|
||||
this.settingsReset = true,
|
||||
this.requiresRestart = false,
|
||||
});
|
||||
|
||||
/// Absolute path of the quarantined store. It contains credentials in
|
||||
/// plaintext: never upload it, attach it to a report, or log its contents.
|
||||
/// Absolute path of the quarantined store. When [backupHoldsCredentials] it
|
||||
/// contains credentials in plaintext: never upload it, attach it to a
|
||||
/// report, or log its contents.
|
||||
final String? backupPath;
|
||||
|
||||
/// Whether the quarantined file may still hold credential material.
|
||||
///
|
||||
/// Derived from the bytes, never from what the salvage recovered: a store
|
||||
/// truncated mid-value keeps most of a vault key or an entire refresh token
|
||||
/// in plaintext while matching no entry at all, because the salvage pattern
|
||||
/// requires the value's closing quote. Only a file proven to contain
|
||||
/// nothing — every byte zero (#1732) — drops the warning, because a warning
|
||||
/// attached to a file of zeros is the one that teaches users to ignore it.
|
||||
final bool backupHoldsCredentials;
|
||||
|
||||
/// Whether [credentialVaultKeyPref] was recovered. When false every stored
|
||||
/// server and profile token becomes undecryptable and must be re-acquired.
|
||||
final bool vaultKeySalvaged;
|
||||
@@ -187,6 +199,11 @@ class SalvagedPrefsCredentials {
|
||||
|
||||
/// Credential slots that were present but could not be decoded.
|
||||
final int losses;
|
||||
|
||||
/// Deliberately no "does this hold credentials" helper. What [salvage]
|
||||
/// recovered says nothing about what the file still contains: a store
|
||||
/// truncated mid-value keeps most of a vault key in plaintext and matches
|
||||
/// nothing. Ask [PrefsStoreShape] about the bytes instead.
|
||||
}
|
||||
|
||||
/// Recovers a damaged desktop preference store without silently destroying
|
||||
@@ -448,28 +465,73 @@ abstract final class PrefsRecovery {
|
||||
|
||||
static String _stamp() => DateTime.now().toUtc().toIso8601String().replaceAll(RegExp(r'[:.]'), '-');
|
||||
|
||||
/// Quarantines the damaged store and returns what was salvaged.
|
||||
/// Reports what a repair would recover, without touching the store.
|
||||
///
|
||||
/// The consent dialog has to name a cost before anything is destroyed, and
|
||||
/// the cost is not uniform: a store that still holds a readable vault key
|
||||
/// keeps every server and profile signed in, while one that does not — an
|
||||
/// all-zero file being the canonical case (#1732) — signs the user out of
|
||||
/// everything. Promising the cheap outcome for the expensive case is worse
|
||||
/// than saying nothing, so the dialog runs this first and branches on it.
|
||||
///
|
||||
/// Pure with respect to the filesystem. [salvage] does register every secret
|
||||
/// it decodes with `LogRedactionManager`, which is idempotent and wanted
|
||||
/// early regardless of whether the user goes on to accept the repair.
|
||||
static Future<SalvagedPrefsCredentials> previewSalvage({File? storeFileOverride}) async {
|
||||
final File file;
|
||||
try {
|
||||
file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) return SalvagedPrefsCredentials.empty;
|
||||
} on Object {
|
||||
return SalvagedPrefsCredentials.empty;
|
||||
}
|
||||
|
||||
try {
|
||||
return salvage(_decodeForSalvage(await file.readAsBytes()));
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
// Only a preview; an unreadable file is the repair's problem to report,
|
||||
// not a reason to fail before the user has even been asked.
|
||||
appLogger.w('Could not preview the damaged preference store', error: error, stackTrace: stackTrace);
|
||||
return SalvagedPrefsCredentials.empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes damaged store bytes as text for the salvage pass.
|
||||
///
|
||||
/// Lossy rather than strict, because `File.readAsString` surfaces a UTF-8
|
||||
/// decode failure as a `FileSystemException` and would abort on exactly the
|
||||
/// damaged store this exists to rescue. For a well-formed document this is
|
||||
/// identical to a strict decode; for a damaged one it still exposes the
|
||||
/// ASCII credential entries.
|
||||
static String _decodeForSalvage(List<int> bytes) => const Utf8Decoder(allowMalformed: true).convert(bytes);
|
||||
|
||||
/// Quarantines the damaged store and returns what was salvaged, alongside
|
||||
/// the measured shape of the bytes that were moved aside.
|
||||
///
|
||||
/// The caller opens a fresh store afterwards and reseeds it; see
|
||||
/// `BaseSharedPreferencesService.repairCorruptStore`.
|
||||
///
|
||||
/// The damaged file is *moved*, never deleted: it is the only remaining copy
|
||||
/// of any credential that could not be salvaged.
|
||||
static Future<({SalvagedPrefsCredentials salvaged, String? backupPath})> quarantine({File? storeFileOverride}) async {
|
||||
///
|
||||
/// [shape] is returned rather than inferred from [salvage] because the two
|
||||
/// answer different questions. Salvage reports what could be *recovered*;
|
||||
/// only the bytes can say whether anything sensitive is still *in there*.
|
||||
/// A store truncated mid-value — the ordinary damage shape — holds most of a
|
||||
/// vault key or an entire refresh token in plaintext while
|
||||
/// [_stringEntryPattern] matches nothing at all, because it requires the
|
||||
/// value's closing quote.
|
||||
static Future<({SalvagedPrefsCredentials salvaged, String? backupPath, PrefsStoreShape? shape})> quarantine({
|
||||
File? storeFileOverride,
|
||||
}) async {
|
||||
final file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) {
|
||||
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null);
|
||||
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null, shape: null);
|
||||
}
|
||||
|
||||
// Read bytes and decode lossily rather than calling `readAsString`, which
|
||||
// surfaces a UTF-8 decode failure as a `FileSystemException` and would
|
||||
// abort the repair on exactly the damaged store it exists to rescue. For a
|
||||
// well-formed document this is identical to a strict decode; for a damaged
|
||||
// one it still exposes the ASCII credential entries to the salvage pass.
|
||||
// A genuine read failure propagates — that is not something a repair fixes.
|
||||
final raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes());
|
||||
|
||||
final salvaged = salvage(raw);
|
||||
final bytes = await file.readAsBytes();
|
||||
final shape = PrefsStoreShape.of(bytes);
|
||||
final salvaged = salvage(_decodeForSalvage(bytes));
|
||||
|
||||
final stamp = _stamp();
|
||||
final backup = File(p.join(file.parent.path, 'shared_preferences.corrupt-$stamp.json'));
|
||||
@@ -489,10 +551,10 @@ abstract final class PrefsRecovery {
|
||||
|
||||
appLogger.w(
|
||||
'Quarantined a corrupt preference store'
|
||||
' (vault key salvaged: ${salvaged.vaultKey != null},'
|
||||
' ($shape; vault key salvaged: ${salvaged.vaultKey != null},'
|
||||
' sessions salvaged: ${salvaged.sessions.length}, lost: ${salvaged.losses})',
|
||||
);
|
||||
return (salvaged: salvaged, backupPath: backup.path);
|
||||
return (salvaged: salvaged, backupPath: backup.path, shape: shape);
|
||||
}
|
||||
|
||||
/// Deletes a quarantined store so the user is not left holding a
|
||||
|
||||
@@ -206,6 +206,11 @@ class StartupFailureRecord {
|
||||
String get headline => '[$phaseId] $errorType: $message';
|
||||
|
||||
/// Full plain-text block for the clipboard and the diagnostics upload.
|
||||
///
|
||||
/// Includes [repairable] because the uploaded text is usually all a
|
||||
/// maintainer gets. Without it a report of "still broken" cannot be told
|
||||
/// apart from "the screen offered no way forward" and "a repair was offered
|
||||
/// and not taken" — the ambiguity that stalled #1732 for two days.
|
||||
String describe() {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('Plezy startup failure')
|
||||
@@ -214,6 +219,7 @@ class StartupFailureRecord {
|
||||
..writeln('When: ${timestamp.toUtc().toIso8601String()}')
|
||||
..writeln('Phase: $phaseId')
|
||||
..writeln('Error: $errorType')
|
||||
..writeln('Repair offered: ${repairable ? 'yes' : 'no'}')
|
||||
..writeln('Message: $message');
|
||||
final stack = stackTrace;
|
||||
if (stack != null && stack.isNotEmpty) {
|
||||
|
||||
@@ -69,13 +69,15 @@ class StartupFailureView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _StartupFailureViewState extends State<StartupFailureView> {
|
||||
late final FocusNode _retryFocusNode = FocusNode(debugLabel: 'startup-failure-retry');
|
||||
/// Holds whichever action leads this screen — Repair when it is offered,
|
||||
/// Retry otherwise, Quit once a restart is owed.
|
||||
late final FocusNode _primaryFocusNode = FocusNode(debugLabel: 'startup-failure-primary');
|
||||
bool _detailsExpanded = false;
|
||||
bool _uploading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_retryFocusNode.dispose();
|
||||
_primaryFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -162,7 +164,11 @@ class _StartupFailureViewState extends State<StartupFailureView> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
restartRequired ? t.startup.restartRequiredBody : t.startup.failedBody,
|
||||
restartRequired
|
||||
? t.startup.restartRequiredBody
|
||||
: repair != null
|
||||
? t.startup.failedBodyRepairable
|
||||
: t.startup.failedBody,
|
||||
key: restartRequired ? startupFailureRestartKey : null,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -181,29 +187,45 @@ class _StartupFailureViewState extends State<StartupFailureView> {
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
if (!restartRequired)
|
||||
FocusableButton(
|
||||
focusNode: _retryFocusNode,
|
||||
autofocus: true,
|
||||
onPressed: canAct ? widget.onRetry : null,
|
||||
child: FilledButton(
|
||||
key: startupBootstrapRetryKey,
|
||||
onPressed: canAct ? widget.onRetry : null,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
// Repair leads whenever it is offered. The failure it
|
||||
// addresses is a document on disk that Retry re-reads
|
||||
// unchanged, so Retry cannot clear it however many times it
|
||||
// is pressed — and it used to be the primary, autofocused,
|
||||
// first-in-order action, which is what a reporter on #1732
|
||||
// pressed repeatedly before concluding the fix had not
|
||||
// shipped. Retry keeps its place for every other failure,
|
||||
// where the environment really can change between attempts.
|
||||
if (!restartRequired && repair != null)
|
||||
FocusableButton(
|
||||
focusNode: _primaryFocusNode,
|
||||
autofocus: true,
|
||||
onPressed: canAct ? () => repair() : null,
|
||||
child: FilledButton.tonal(
|
||||
child: FilledButton(
|
||||
key: startupFailureRepairKey,
|
||||
onPressed: canAct ? () => repair() : null,
|
||||
child: Text(t.startup.repairStorage),
|
||||
),
|
||||
),
|
||||
if (!restartRequired)
|
||||
FocusableButton(
|
||||
focusNode: repair == null ? _primaryFocusNode : null,
|
||||
autofocus: repair == null,
|
||||
onPressed: canAct ? widget.onRetry : null,
|
||||
child: repair == null
|
||||
? FilledButton(
|
||||
key: startupBootstrapRetryKey,
|
||||
onPressed: canAct ? widget.onRetry : null,
|
||||
child: Text(t.common.retry),
|
||||
)
|
||||
: OutlinedButton(
|
||||
key: startupBootstrapRetryKey,
|
||||
onPressed: canAct ? widget.onRetry : null,
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
if (restartRequired && PlatformDetector.isDesktopOS())
|
||||
FocusableButton(
|
||||
focusNode: _retryFocusNode,
|
||||
focusNode: _primaryFocusNode,
|
||||
autofocus: true,
|
||||
onPressed: enabled ? _quit : null,
|
||||
child: FilledButton(
|
||||
|
||||
@@ -146,6 +146,80 @@ void main() {
|
||||
await expectLater(SettingsService.getInstance(), completes);
|
||||
});
|
||||
|
||||
test('the all-zero store reported in #1732 is repairable and costs every sign-in', () async {
|
||||
// The store the reporter actually had: a full-length file whose every byte
|
||||
// is zero. It reaches the parser as "FormatException at offset 0", exactly
|
||||
// like a truncated document, but nothing can be salvaged from it — which
|
||||
// is the opposite of harmless, because the vault key goes with it.
|
||||
await store.writeAsBytes(List<int>.filled(10336, 0));
|
||||
|
||||
final error = await SettingsService.getInstance().then<Object?>((_) => null, onError: (Object e) => e);
|
||||
final corruption = error! as CorruptPreferenceStoreException;
|
||||
expect(corruption.shape?.allZero, isTrue);
|
||||
expect(corruption.shape?.length, 10336);
|
||||
|
||||
// The consent dialog reads this before destroying anything, and must not
|
||||
// promise that servers and profiles survive.
|
||||
final preview = await PrefsRecovery.previewSalvage(storeFileOverride: store);
|
||||
expect(preview.vaultKey, isNull);
|
||||
expect(preview.sessions, isEmpty);
|
||||
// Purely a preview: the damaged store is still exactly where it was.
|
||||
expect(await store.exists(), isTrue);
|
||||
expect(await store.length(), 10336);
|
||||
|
||||
// Retrying re-reads the same bytes and reaches the same verdict, so the
|
||||
// failure screen's Retry button can never clear this on its own.
|
||||
await expectLater(SettingsService.getInstance(), throwsA(isA<CorruptPreferenceStoreException>()));
|
||||
|
||||
final outcome = await BaseSharedPreferencesService.repairCorruptStore();
|
||||
|
||||
expect(outcome.requiresRestart, isFalse);
|
||||
expect(outcome.vaultKeySalvaged, isFalse);
|
||||
// A file of zeros is not a secret, so the outcome dialog must not tell the
|
||||
// user the retained copy holds their credentials.
|
||||
expect(outcome.backupHoldsCredentials, isFalse);
|
||||
// The repair is what actually gets them into the app, in this process.
|
||||
await expectLater(SettingsService.getInstance(), completes);
|
||||
});
|
||||
|
||||
test('a salvageable store previews as keeping its sign-ins', () async {
|
||||
await store.writeAsBytes([
|
||||
0,
|
||||
...utf8.encode(jsonEncode({credentialVaultKeyPref: validVaultKey})),
|
||||
]);
|
||||
|
||||
final preview = await PrefsRecovery.previewSalvage(storeFileOverride: store);
|
||||
|
||||
expect(preview.vaultKey, validVaultKey);
|
||||
|
||||
final outcome = await BaseSharedPreferencesService.repairCorruptStore();
|
||||
expect(outcome.vaultKeySalvaged, isTrue);
|
||||
expect(outcome.backupHoldsCredentials, isTrue);
|
||||
});
|
||||
|
||||
test('a store truncated mid-credential still warns that the copy holds secrets', () async {
|
||||
// The ordinary damage shape, and the one that makes "what did salvage
|
||||
// recover" useless as a proxy for "what is still in the file". The value
|
||||
// has no closing quote, so the salvage pattern matches nothing and reports
|
||||
// no losses — while almost the entire vault key sits in the quarantined
|
||||
// copy in plaintext.
|
||||
final truncated = '{"$credentialVaultKeyPref":"${validVaultKey.substring(0, validVaultKey.length - 4)}';
|
||||
await store.writeAsString(truncated);
|
||||
|
||||
final preview = await PrefsRecovery.previewSalvage(storeFileOverride: store);
|
||||
expect(preview.vaultKey, isNull);
|
||||
expect(preview.sessions, isEmpty);
|
||||
expect(preview.losses, 0);
|
||||
|
||||
final outcome = await BaseSharedPreferencesService.repairCorruptStore();
|
||||
|
||||
expect(outcome.vaultKeySalvaged, isFalse);
|
||||
// The copy is unmistakably sensitive even though nothing was recoverable.
|
||||
expect(outcome.backupHoldsCredentials, isTrue);
|
||||
final quarantined = await File(outcome.backupPath!).readAsString();
|
||||
expect(quarantined, contains(validVaultKey.substring(0, 20)));
|
||||
});
|
||||
|
||||
test('a genuine read failure keeps its own type and stays non-repairable', () async {
|
||||
// A store the process cannot read is not a damaged *document*. Offering a
|
||||
// destructive repair for a permissions problem would reset every setting
|
||||
|
||||
@@ -13,12 +13,14 @@ StartupFailureRecord _record({
|
||||
StartupPhase? phase = StartupPhase.database,
|
||||
Object error = const FormatException('boom'),
|
||||
StackTrace? stackTrace,
|
||||
bool repairable = false,
|
||||
}) => StartupFailureRecord.fromError(
|
||||
phase: phase,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
appVersion: '2.11.0+124',
|
||||
platform: 'windows 11',
|
||||
repairable: repairable,
|
||||
);
|
||||
|
||||
void main() {
|
||||
@@ -120,6 +122,14 @@ void main() {
|
||||
expect(text, contains('windows 11'));
|
||||
});
|
||||
|
||||
test('describe() states whether a repair was on offer', () {
|
||||
// An uploaded record is usually the only artefact a maintainer gets. On
|
||||
// #1732 it could not distinguish "the screen offered no way forward"
|
||||
// from "a repair was offered and not taken", and the thread stalled.
|
||||
expect(_record(repairable: true).describe(), contains('Repair offered: yes'));
|
||||
expect(_record().describe(), contains('Repair offered: no'));
|
||||
});
|
||||
|
||||
test('headline stays one line', () {
|
||||
expect(_record().headline, startsWith('[database] FormatException'));
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ Future<void> _pumpView(
|
||||
WidgetTester tester, {
|
||||
required bool restartRequired,
|
||||
Future<bool> Function()? onExitRequested,
|
||||
bool repairable = true,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
@@ -30,7 +31,7 @@ Future<void> _pumpView(
|
||||
failure: _record(),
|
||||
restartRequired: restartRequired,
|
||||
onRetry: () {},
|
||||
onRepair: () async {},
|
||||
onRepair: repairable ? () async {} : null,
|
||||
requestExit: ({AppExitApplication? exitApplicationForTesting}) async =>
|
||||
onExitRequested == null ? false : await onExitRequested(),
|
||||
),
|
||||
@@ -41,6 +42,14 @@ Future<void> _pumpView(
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
/// Whether [key] resolves to the filled (primary) Material button.
|
||||
bool _isPrimary(WidgetTester tester, Key key) => tester.widget(find.byKey(key)) is FilledButton;
|
||||
|
||||
/// Whether [key]'s enclosing focusable currently holds focus.
|
||||
bool _hasFocus(WidgetTester tester, Key key) =>
|
||||
tester.widget<Focus>(find.ancestor(of: find.byKey(key), matching: find.byType(Focus)).first).focusNode?.hasFocus ??
|
||||
false;
|
||||
|
||||
void main() {
|
||||
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
@@ -53,6 +62,31 @@ void main() {
|
||||
expect(find.byKey(startupFailureRestartKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('repair leads the screen whenever it is offered', (tester) async {
|
||||
await _pumpView(tester, restartRequired: false);
|
||||
|
||||
// Retry re-reads the same damaged document, so it can never clear this
|
||||
// failure. Presenting it as the primary, autofocused action is what left
|
||||
// the #1732 reporter pressing it and concluding the fix had not shipped.
|
||||
expect(_isPrimary(tester, startupFailureRepairKey), isTrue);
|
||||
expect(_isPrimary(tester, startupBootstrapRetryKey), isFalse);
|
||||
await tester.pump();
|
||||
expect(_hasFocus(tester, startupFailureRepairKey), isTrue);
|
||||
expect(find.text(t.startup.failedBodyRepairable), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('retry leads when no repair can address the failure', (tester) async {
|
||||
await _pumpView(tester, restartRequired: false, repairable: false);
|
||||
|
||||
// A locked database or a denied directory can genuinely change between
|
||||
// attempts, so Retry keeps both the primary styling and the focus there.
|
||||
expect(find.byKey(startupFailureRepairKey), findsNothing);
|
||||
expect(_isPrimary(tester, startupBootstrapRetryKey), isTrue);
|
||||
await tester.pump();
|
||||
expect(_hasFocus(tester, startupBootstrapRetryKey), isTrue);
|
||||
expect(find.text(t.startup.failedBody), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a pending restart withdraws every action that would touch the store', (tester) async {
|
||||
await _pumpView(tester, restartRequired: true);
|
||||
|
||||
|
||||
@@ -59,6 +59,71 @@ void main() {
|
||||
expect(find.text(backup.path), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a backup with nothing in it still offers deletion but claims no secrets', (tester) async {
|
||||
// The #1732 store was 10336 zero bytes. Telling the user that copy holds
|
||||
// their sign-ins is false, and a warning that cries wolf is the one they
|
||||
// will ignore on the launch where the file really is sensitive.
|
||||
await _openDialog(
|
||||
tester,
|
||||
PrefsRepairOutcome(
|
||||
backupPath: backup.path,
|
||||
backupHoldsCredentials: false,
|
||||
vaultKeySalvaged: false,
|
||||
sessionsSalvaged: 0,
|
||||
sessionsLost: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(t.startup.backupTitle), findsOneWidget);
|
||||
expect(find.text(backup.path), findsOneWidget);
|
||||
expect(find.text(t.startup.backupWarning), findsNothing);
|
||||
expect(find.text(t.startup.deleteBackup), findsOneWidget);
|
||||
});
|
||||
|
||||
group('consent copy', () {
|
||||
test('a store with no recoverable vault key promises nothing it cannot keep', () {
|
||||
final message = repairConsentMessage(oneCredential: false, signInsSurvive: false);
|
||||
|
||||
// The #1732 store salvaged nothing. Saying "servers and profiles
|
||||
// normally stay signed in" here would be contradicted by the outcome
|
||||
// dialog moments later.
|
||||
expect(message, contains(t.startup.repairBodyCommon));
|
||||
expect(message, contains(t.startup.repairBodySignInsLost));
|
||||
expect(message, isNot(contains(t.startup.repairBodySignInsKept)));
|
||||
});
|
||||
|
||||
test('a salvageable store keeps the cheaper promise', () {
|
||||
final message = repairConsentMessage(oneCredential: false, signInsSurvive: true);
|
||||
|
||||
expect(message, contains(t.startup.repairBodySignInsKept));
|
||||
expect(message, isNot(contains(t.startup.repairBodySignInsLost)));
|
||||
});
|
||||
|
||||
test('the single-credential repair names its own narrower scope', () {
|
||||
final message = repairConsentMessage(oneCredential: true, signInsSurvive: true);
|
||||
|
||||
expect(message, contains(t.startup.repairBodyOneCredential));
|
||||
expect(message, isNot(contains(t.startup.repairBodyCommon)));
|
||||
});
|
||||
|
||||
test('trackers and Seerr get the same cautious sentence either way', () {
|
||||
// Their sessions are plaintext preference entries, salvaged one by one
|
||||
// and left untouched by the single-credential repair, so they neither
|
||||
// survive nor die with the vault key. Neither branch may borrow that
|
||||
// value's verdict for them.
|
||||
for (final signInsSurvive in [true, false]) {
|
||||
expect(
|
||||
repairConsentMessage(oneCredential: false, signInsSurvive: signInsSurvive),
|
||||
contains(t.startup.repairBodySessionsUncertain),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
repairConsentMessage(oneCredential: true, signInsSurvive: false),
|
||||
contains(t.startup.repairBodySessionsUncertain),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('deleting the backup removes the file and stops showing its path', (tester) async {
|
||||
final deleted = <String>[];
|
||||
await _openDialog(
|
||||
|
||||
Reference in New Issue
Block a user