fix(startup): report and repair a failed launch instead of showing "Error"
Since 2.10.0 the whole app sits behind one all-or-nothing initialization gate, and that gate discarded the only evidence of its own failure. It caught the error, logged nothing but `error.runtimeType`, rendered an icon plus the word "Error" plus Retry, and never reported the error because catching it kept the crash reporter from ever seeing it. There is no log file on any platform, the buffer is in memory only, a double-clicked Windows release build has no console, and the log viewer lives in Settings, behind the gate that just failed. #1732 is the result: a Windows 11 user whose app will not boot and who cannot produce a single byte of diagnostic detail. The gate now names its phases. Each step is wrapped so a throw carries the phase it came from, replacing a `Future.wait` that discarded every error but the first and could not attribute it to any of four concurrent steps. The failure screen renders the phase, the exception type, the message and an expandable stack, plus copy and upload actions that reuse the existing log-relay flow. The record is persisted next to the database so the next successful launch can surface it in Settings > Logs, and it is reported to the crash reporter explicitly. Only preferences and the database still gate the launch. Window chrome, locale, crash-reporting init, TV/performance detection, the image-cache budget and download storage are best-effort and time-bounded, so a stalled platform thread degrades instead of holding the splash forever. Sentry no longer receives the startup work as its `appRunner`: that made a startup failure indistinguishable from a Sentry failure, and the guard would then have re-run migrations and the database open a second time. The two remaining fatal steps become recoverable. Preference reads tolerate a value whose stored type no longer matches, dropping the key and defaulting instead of failing the boot. A store that cannot be parsed is detected before either desktop plugin backend can memoise it, which is what makes an in-process repair possible at all. Repair is never automatic: it states what it will cost, salvages the credential-vault key and every tracker and Seerr session it can validate out of the damaged bytes, reseeds them, and moves the original aside rather than deleting it. Servers and profiles survive a salvaged key because their tokens are ciphertext in the database; tracker and Seerr sessions are plaintext preference entries, so the copy says they may still need reconnecting. Nothing derived from the store reaches a diagnostic. `FormatException` prints an excerpt of whatever it failed to parse, and during startup that document holds the vault key, refresh tokens and session cookies while the redaction manager still has nothing registered, so the wrapper keeps only the cause's type and offset and the record is an allowlist of already-redacted fields. The quarantined copy is labelled as containing credentials, is never offered for upload, and can be deleted from the dialog. Also self-heals orphaned WAL/SHM sidecars on desktop rather than only tvOS, makes every `createTable` migration step idempotent, keeps MSVC link by-products out of the Windows bundle, and asserts bundle contents in CI. Refs #1732
This commit is contained in:
@@ -466,6 +466,63 @@ jobs:
|
||||
shell: pwsh
|
||||
run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
||||
|
||||
- name: Verify Windows bundle contents
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "build/windows/${{ matrix.arch }}/runner/Release"
|
||||
if (-not (Test-Path -LiteralPath $bundleDir -PathType Container)) {
|
||||
throw "Windows bundle directory does not exist: $bundleDir"
|
||||
}
|
||||
|
||||
$bundlePath = (Resolve-Path -LiteralPath $bundleDir).Path
|
||||
$foundFiles = @(
|
||||
Get-ChildItem -LiteralPath $bundlePath -File -Recurse |
|
||||
ForEach-Object {
|
||||
$_.FullName.Substring($bundlePath.Length + 1).Replace('\', '/')
|
||||
} |
|
||||
Sort-Object
|
||||
)
|
||||
|
||||
Write-Host "Files found in ${bundleDir}:"
|
||||
$foundFiles | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
$requiredFiles = @(
|
||||
"plezy.exe"
|
||||
"flutter_windows.dll"
|
||||
"sqlite3.dll"
|
||||
"libmpv-2.dll"
|
||||
"data/app.so"
|
||||
"data/icudtl.dat"
|
||||
"data/flutter_assets/NativeAssetsManifest.json"
|
||||
)
|
||||
$forbiddenFiles = @(
|
||||
"plezy.lib"
|
||||
"plezy.exp"
|
||||
"simdutf.lib"
|
||||
)
|
||||
|
||||
$bundleErrors = @()
|
||||
foreach ($file in $requiredFiles) {
|
||||
if ($foundFiles -notcontains $file) {
|
||||
Write-Output "::error::Required Windows bundle file is missing: $file"
|
||||
$bundleErrors += "missing $file"
|
||||
}
|
||||
}
|
||||
foreach ($file in $forbiddenFiles) {
|
||||
if ($foundFiles -contains $file) {
|
||||
Write-Output "::error::Forbidden link by-product is present in the Windows bundle: $file"
|
||||
$bundleErrors += "present $file"
|
||||
}
|
||||
}
|
||||
|
||||
# sentry.dll is the inproc-backend native SDK; crashpad_handler.exe is
|
||||
# intentionally absent (windows/CMakeLists.txt sets
|
||||
# SENTRY_NATIVE_BACKEND=inproc), so do not assert on it here.
|
||||
|
||||
if ($bundleErrors.Count -gt 0) {
|
||||
throw "Windows bundle verification failed: $($bundleErrors -join '; ')"
|
||||
}
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
shell: pwsh
|
||||
|
||||
@@ -85,9 +85,10 @@ class AppDatabase extends _$AppDatabase {
|
||||
static final Object _durabilityZoneKey = Object();
|
||||
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
|
||||
|
||||
/// Resolves and opens the production database, eagerly completing Drift
|
||||
/// setup and migrations on non-tvOS before returning. tvOS recovery retains
|
||||
/// ownership of database access ordering.
|
||||
/// Resolves and opens the production database, removing orphaned WAL/SHM
|
||||
/// sidecars when the main database is absent (#1732), then eagerly completing
|
||||
/// Drift setup and migrations on non-tvOS. tvOS recovery retains ownership
|
||||
/// of database access ordering.
|
||||
static Future<AppDatabaseBootstrap> open({
|
||||
bool isTvos = const bool.fromEnvironment('TVOS_BUILD'),
|
||||
File? databaseFile,
|
||||
@@ -105,7 +106,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
final databaseExisted = await file.exists();
|
||||
if (isTvos && !databaseExisted) {
|
||||
if (!databaseExisted) {
|
||||
await _removeOrphanedDatabaseSidecars(file);
|
||||
}
|
||||
|
||||
@@ -379,7 +380,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
if (from < 7) {
|
||||
appLogger.i('Adding OfflineWatchProgress table (v7 migration)');
|
||||
await m.createTable(offlineWatchProgress);
|
||||
await _ignoreAlreadyExists('OfflineWatchProgress table', () => m.createTable(offlineWatchProgress));
|
||||
}
|
||||
if (from < 8) {
|
||||
appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)');
|
||||
@@ -397,7 +398,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 10) {
|
||||
appLogger.i('Adding SyncRules table (v10 migration)');
|
||||
await m.createTable(syncRules);
|
||||
await _ignoreAlreadyExists('SyncRules table', () => m.createTable(syncRules));
|
||||
}
|
||||
if (from < 11) {
|
||||
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
|
||||
@@ -427,13 +428,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
'Adding Connections, Profiles, ProfileConnections, DownloadOwners + scope/profile columns (v14 migration)',
|
||||
);
|
||||
|
||||
await m.createTable(connections);
|
||||
await _ignoreAlreadyExists('Connections table', () => m.createTable(connections));
|
||||
await _ignoreAlreadyExists('Index idx_connections_kind', () => m.create(idxConnectionsKind));
|
||||
|
||||
await m.createTable(profiles);
|
||||
await _ignoreAlreadyExists('Profiles table', () => m.createTable(profiles));
|
||||
await _ignoreAlreadyExists('Index idx_profiles_kind', () => m.create(idxProfilesKind));
|
||||
|
||||
await m.createTable(profileConnections);
|
||||
await _ignoreAlreadyExists('ProfileConnections table', () => m.createTable(profileConnections));
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_profile_connections_connection_id',
|
||||
() => m.create(idxProfileConnectionsConnectionId),
|
||||
@@ -1292,10 +1293,17 @@ Future<File> _resolveProductionDatabaseFile() async {
|
||||
return File(p.join(dbFolder.path, 'plezy_downloads.db'));
|
||||
}
|
||||
|
||||
/// Best-effort removal for sidecars left without a main database after an
|
||||
/// interrupted write. This runs on every platform but only when the caller has
|
||||
/// confirmed the main database is absent (#1732).
|
||||
Future<void> _removeOrphanedDatabaseSidecars(File databaseFile) async {
|
||||
for (final suffix in const ['-wal', '-shm']) {
|
||||
final sidecar = File('${databaseFile.path}$suffix');
|
||||
try {
|
||||
if (await sidecar.exists()) await sidecar.delete();
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
appLogger.w('Unable to remove orphaned database sidecar ${sidecar.path}', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Jurnalları kopyala",
|
||||
"uploadLogs": "Jurnalları yüklə"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Əlaqəli paketlər",
|
||||
"license": "Lisenziya",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Копирай логовете",
|
||||
"uploadLogs": "Качи логовете"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Свързани пакети",
|
||||
"license": "Лиценз",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Kopiér logfiler",
|
||||
"uploadLogs": "Upload logfiler"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Relaterede pakker",
|
||||
"license": "Licens",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Protokolle kopieren",
|
||||
"uploadLogs": "Protokolle hochladen"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Verwandte Pakete",
|
||||
"license": "Lizenz",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Copy Logs",
|
||||
"uploadLogs": "Upload Logs"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "Plezy could not start",
|
||||
"failedBody": "Something went wrong during startup. The details below identify what failed.",
|
||||
"phaseLabel": "Step",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"copyDetails": "Copy details",
|
||||
"detailsCopied": "Details copied to clipboard",
|
||||
"uploadDetails": "Upload details",
|
||||
"repairStorage": "Repair storage",
|
||||
"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.",
|
||||
"repairConfirm": "Repair",
|
||||
"repairSucceeded": "Storage repaired",
|
||||
"repairNeedsRestart": "Storage repaired — restart required",
|
||||
"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.",
|
||||
"repairLostSessions": "At least one tracker or Seerr connection was lost and has to be reconnected.",
|
||||
"backupTitle": "A copy of the damaged file was kept",
|
||||
"backupWarning": "It contains your sign-in credentials. Do not upload or share it.",
|
||||
"deleteBackup": "Delete copy",
|
||||
"backupDeleted": "Copy deleted.",
|
||||
"previousFailureTitle": "Plezy failed to start last time"
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Related Packages",
|
||||
"license": "License",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Copiar registros",
|
||||
"uploadLogs": "Subir registros"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Paquetes relacionados",
|
||||
"license": "Licencia",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Copier les journaux",
|
||||
"uploadLogs": "Envoyer les journaux"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Paquets associés",
|
||||
"license": "Licence",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Naplók másolása",
|
||||
"uploadLogs": "Naplók feltöltése"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Kapcsolódó csomagok",
|
||||
"license": "Licenc",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Copia log",
|
||||
"uploadLogs": "Carica log"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Pacchetti correlati",
|
||||
"license": "Licenza",
|
||||
|
||||
@@ -968,6 +968,33 @@
|
||||
"copyLogs": "ログをコピー",
|
||||
"uploadLogs": "ログをアップロード"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "関連パッケージ",
|
||||
"license": "ライセンス",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Журналдарды көшіру",
|
||||
"uploadLogs": "Журналдарды жүктеу"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Байланысты пакеттер",
|
||||
"license": "Лицензия",
|
||||
|
||||
@@ -968,6 +968,33 @@
|
||||
"copyLogs": "로그 복사",
|
||||
"uploadLogs": "로그 업로드"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "관련 소프트웨어 패키지",
|
||||
"license": "라이선스",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Kopier logger",
|
||||
"uploadLogs": "Last opp logger"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Relaterte pakker",
|
||||
"license": "Lisens",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Logbestanden kopiëren",
|
||||
"uploadLogs": "Logbestanden uploaden"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Gerelateerde pakketten",
|
||||
"license": "Licentie",
|
||||
|
||||
@@ -971,6 +971,33 @@
|
||||
"copyLogs": "Kopiuj logi",
|
||||
"uploadLogs": "Prześlij logi"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Powiązane pakiety",
|
||||
"license": "Licencja",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Copiar Logs",
|
||||
"uploadLogs": "Enviar Logs"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Pacotes Relacionados",
|
||||
"license": "Licença",
|
||||
|
||||
@@ -971,6 +971,33 @@
|
||||
"copyLogs": "Скопировать логи",
|
||||
"uploadLogs": "Загрузить логи"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Связанные пакеты",
|
||||
"license": "Лицензия",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 32913 (1496 per locale)
|
||||
/// Strings: 32938 (1497 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
+114
-4
@@ -68,6 +68,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||
late final Translations$serverSelection$en serverSelection = Translations$serverSelection$en.internal(_root);
|
||||
late final Translations$hubDetail$en hubDetail = Translations$hubDetail$en.internal(_root);
|
||||
late final Translations$logs$en logs = Translations$logs$en.internal(_root);
|
||||
late final Translations$startup$en startup = Translations$startup$en.internal(_root);
|
||||
late final Translations$licenses$en licenses = Translations$licenses$en.internal(_root);
|
||||
late final Translations$navigation$en navigation = Translations$navigation$en.internal(_root);
|
||||
late final Translations$explore$en explore = Translations$explore$en.internal(_root);
|
||||
@@ -2830,6 +2831,90 @@ class Translations$logs$en {
|
||||
String get uploadLogs => 'Upload Logs';
|
||||
}
|
||||
|
||||
// Path: startup
|
||||
class Translations$startup$en {
|
||||
Translations$startup$en.internal(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Plezy could not start'
|
||||
String get failedTitle => 'Plezy could not start';
|
||||
|
||||
/// 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: 'Step'
|
||||
String get phaseLabel => 'Step';
|
||||
|
||||
/// en: 'Show details'
|
||||
String get showDetails => 'Show details';
|
||||
|
||||
/// en: 'Hide details'
|
||||
String get hideDetails => 'Hide details';
|
||||
|
||||
/// en: 'Copy details'
|
||||
String get copyDetails => 'Copy details';
|
||||
|
||||
/// en: 'Details copied to clipboard'
|
||||
String get detailsCopied => 'Details copied to clipboard';
|
||||
|
||||
/// en: 'Upload details'
|
||||
String get uploadDetails => 'Upload details';
|
||||
|
||||
/// en: 'Repair storage'
|
||||
String get repairStorage => 'Repair storage';
|
||||
|
||||
/// en: 'Repair stored data?'
|
||||
String get repairTitle => 'Repair stored data?';
|
||||
|
||||
/// en: 'Plezy's settings file is damaged and cannot be read. Repairing resets every setting to its default.'
|
||||
String get repairBodyCommon => 'Plezy\'s settings file is damaged and cannot be read. Repairing resets every setting to its default.';
|
||||
|
||||
/// 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: 'Repair'
|
||||
String get repairConfirm => 'Repair';
|
||||
|
||||
/// en: 'Storage repaired'
|
||||
String get repairSucceeded => 'Storage repaired';
|
||||
|
||||
/// en: 'Storage repaired — restart required'
|
||||
String get repairNeedsRestart => 'Storage repaired — restart required';
|
||||
|
||||
/// en: 'Repair failed'
|
||||
String get repairFailed => 'Repair failed';
|
||||
|
||||
/// en: 'Your servers and profiles are still signed in.'
|
||||
String get repairKeptSignIns => 'Your servers and profiles are still signed in.';
|
||||
|
||||
/// en: 'The key protecting your saved sign-ins could not be recovered. You will have to sign in to every server and profile again.'
|
||||
String get repairLostSignIns => 'The key protecting your saved sign-ins could not be recovered. You will have to sign in to every server and profile again.';
|
||||
|
||||
/// en: 'At least one tracker or Seerr connection was lost and has to be reconnected.'
|
||||
String get repairLostSessions => 'At least one tracker or Seerr connection was lost and has to be reconnected.';
|
||||
|
||||
/// en: 'A copy of the damaged file was kept'
|
||||
String get backupTitle => 'A copy of the damaged file was kept';
|
||||
|
||||
/// en: 'It contains your sign-in credentials. Do not upload or share it.'
|
||||
String get backupWarning => 'It contains your sign-in credentials. Do not upload or share it.';
|
||||
|
||||
/// en: 'Delete copy'
|
||||
String get deleteBackup => 'Delete copy';
|
||||
|
||||
/// en: 'Copy deleted.'
|
||||
String get backupDeleted => 'Copy deleted.';
|
||||
|
||||
/// en: 'Plezy failed to start last time'
|
||||
String get previousFailureTitle => 'Plezy failed to start last time';
|
||||
}
|
||||
|
||||
// Path: licenses
|
||||
class Translations$licenses$en {
|
||||
Translations$licenses$en.internal(this._root);
|
||||
@@ -6825,6 +6910,31 @@ extension on Translations {
|
||||
'logs.clearLogs' => 'Clear Logs',
|
||||
'logs.copyLogs' => 'Copy Logs',
|
||||
'logs.uploadLogs' => 'Upload Logs',
|
||||
'startup.failedTitle' => 'Plezy could not start',
|
||||
'startup.failedBody' => 'Something went wrong during startup. The details below identify what failed.',
|
||||
'startup.phaseLabel' => 'Step',
|
||||
'startup.showDetails' => 'Show details',
|
||||
'startup.hideDetails' => 'Hide details',
|
||||
'startup.copyDetails' => 'Copy details',
|
||||
'startup.detailsCopied' => 'Details copied to clipboard',
|
||||
'startup.uploadDetails' => 'Upload details',
|
||||
'startup.repairStorage' => 'Repair storage',
|
||||
'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.repairConfirm' => 'Repair',
|
||||
'startup.repairSucceeded' => 'Storage repaired',
|
||||
'startup.repairNeedsRestart' => 'Storage repaired — restart required',
|
||||
'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.',
|
||||
'startup.repairLostSessions' => 'At least one tracker or Seerr connection was lost and has to be reconnected.',
|
||||
'startup.backupTitle' => 'A copy of the damaged file was kept',
|
||||
'startup.backupWarning' => 'It contains your sign-in credentials. Do not upload or share it.',
|
||||
'startup.deleteBackup' => 'Delete copy',
|
||||
'startup.backupDeleted' => 'Copy deleted.',
|
||||
'startup.previousFailureTitle' => 'Plezy failed to start last time',
|
||||
'licenses.relatedPackages' => 'Related Packages',
|
||||
'licenses.license' => 'License',
|
||||
'licenses.licenseNumber' => ({required Object number}) => 'License ${number}',
|
||||
@@ -6926,6 +7036,8 @@ extension on Translations {
|
||||
'explore.sourceMaterial.game' => 'Game',
|
||||
'explore.sourceMaterial.webComic' => 'Web comic',
|
||||
'explore.sourceMaterial.musicRelease' => 'Music',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.sourceMaterial.otherMedia' => 'Other',
|
||||
'explore.creditRole.director' => 'Director',
|
||||
'explore.creditRole.writer' => 'Writer',
|
||||
@@ -6951,8 +7063,6 @@ extension on Translations {
|
||||
'explore.detail.country' => 'Country',
|
||||
'explore.detail.language' => 'Language',
|
||||
'explore.detail.released' => 'Released',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.detail.physicalRelease' => 'On disc',
|
||||
'explore.detail.ended' => 'Ended',
|
||||
'explore.detail.addedOn' => ({required Object date}) => 'Added ${date}',
|
||||
@@ -7440,6 +7550,8 @@ extension on Translations {
|
||||
'metadataEdit.artwork' => 'Artwork',
|
||||
'metadataEdit.advancedSettings' => 'Advanced Settings',
|
||||
'metadataEdit.title' => 'Title',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.sortTitle' => 'Sort Title',
|
||||
'metadataEdit.originalTitle' => 'Original Title',
|
||||
'metadataEdit.releaseDate' => 'Release Date',
|
||||
@@ -7465,8 +7577,6 @@ extension on Translations {
|
||||
'metadataEdit.artworkUpdateFailed' => 'Failed to update artwork',
|
||||
'metadataEdit.noArtworkAvailable' => 'No artwork available',
|
||||
'metadataEdit.artworkOption' => ({required Object index}) => 'Artwork option ${index}',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.selectedArtworkOption' => ({required Object index}) => 'Artwork option ${index}, selected',
|
||||
'metadataEdit.notSet' => 'Not set',
|
||||
'metadataEdit.libraryDefault' => 'Library default',
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Kopiera loggar",
|
||||
"uploadLogs": "Ladda upp loggar"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Relaterade paket",
|
||||
"license": "Licens",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Günlükleri Kopyala",
|
||||
"uploadLogs": "Günlükleri Yükle"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "İlgili Paketler",
|
||||
"license": "Lisans",
|
||||
|
||||
@@ -969,6 +969,33 @@
|
||||
"copyLogs": "Jurnallarni nusxalash",
|
||||
"uploadLogs": "Jurnallarni yuklash"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "Bogʻliq paketlar",
|
||||
"license": "Litsenziya",
|
||||
|
||||
@@ -968,6 +968,33 @@
|
||||
"copyLogs": "複製日誌",
|
||||
"uploadLogs": "上傳日誌"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "相關套件",
|
||||
"license": "授權",
|
||||
|
||||
@@ -968,6 +968,33 @@
|
||||
"copyLogs": "复制日志",
|
||||
"uploadLogs": "上传日志"
|
||||
},
|
||||
"startup": {
|
||||
"failedTitle": "",
|
||||
"failedBody": "",
|
||||
"phaseLabel": "",
|
||||
"showDetails": "",
|
||||
"hideDetails": "",
|
||||
"copyDetails": "",
|
||||
"detailsCopied": "",
|
||||
"uploadDetails": "",
|
||||
"repairStorage": "",
|
||||
"repairTitle": "",
|
||||
"repairBodyCommon": "",
|
||||
"repairBodyOneCredential": "",
|
||||
"repairBodySessionsAtRisk": "",
|
||||
"repairConfirm": "",
|
||||
"repairSucceeded": "",
|
||||
"repairNeedsRestart": "",
|
||||
"repairFailed": "",
|
||||
"repairKeptSignIns": "",
|
||||
"repairLostSignIns": "",
|
||||
"repairLostSessions": "",
|
||||
"backupTitle": "",
|
||||
"backupWarning": "",
|
||||
"deleteBackup": "",
|
||||
"backupDeleted": "",
|
||||
"previousFailureTitle": ""
|
||||
},
|
||||
"licenses": {
|
||||
"relatedPackages": "相关软件包",
|
||||
"license": "许可证",
|
||||
|
||||
+346
-62
@@ -76,7 +76,6 @@ import 'i18n/app_locale_utils.dart';
|
||||
import 'i18n/strings.g.dart';
|
||||
import 'widgets/app_icon.dart';
|
||||
import 'focus/input_mode_tracker.dart';
|
||||
import 'focus/focusable_button.dart';
|
||||
import 'focus/key_event_utils.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'utils/navigation_transitions.dart';
|
||||
@@ -84,6 +83,12 @@ import 'utils/log_redaction_manager.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
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/startup_diagnostics.dart';
|
||||
import 'utils/dialogs.dart';
|
||||
import 'widgets/dialog_action_button.dart';
|
||||
import 'widgets/startup_failure_view.dart';
|
||||
|
||||
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
||||
const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
|
||||
@@ -142,6 +147,9 @@ void _bootstrapApp() {
|
||||
return const ColoredBox(color: Color(0xFF000000));
|
||||
};
|
||||
|
||||
// Off the critical path: the version label only decorates a diagnostic.
|
||||
unawaited(_primeDiagnosticsVersion());
|
||||
|
||||
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.runApp);
|
||||
runApp(
|
||||
StartupBootstrap<_StartupDependencies>(
|
||||
@@ -160,23 +168,69 @@ void _bootstrapApp() {
|
||||
);
|
||||
}
|
||||
|
||||
Future<_StartupDependencies> _initializeApplication() async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
setLoggerLevel(settings.read(SettingsService.enableDebugLogging));
|
||||
|
||||
_StartupDependencies? dependencies;
|
||||
Future<void> initializeStartup() async {
|
||||
AndroidExitDiagnostics.markTelemetryReady();
|
||||
dependencies = await _initializeStartup(settings);
|
||||
/// Wraps [step] so a failure names the gate phase it came from.
|
||||
///
|
||||
/// `Future.wait` keeps only the first error and discards the rest, and the old
|
||||
/// catch-all reported a bare `error.runtimeType`, so a startup failure could
|
||||
/// not even be attributed to one of four concurrent steps (#1732).
|
||||
Future<T> _gatePhase<T>(StartupPhase phase, Future<T> Function() step) async {
|
||||
try {
|
||||
return await step();
|
||||
} catch (error, stackTrace) {
|
||||
Error.throwWithStackTrace(StartupPhaseException(phase, error), stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a degradable startup step may block the launch before it is
|
||||
/// abandoned. Generous next to the sub-second these normally take, but bounded:
|
||||
/// `windowManager.ensureInitialized()` is a platform-channel round trip and the
|
||||
/// Windows runner puts the UI on its own thread, so a stalled platform thread
|
||||
/// would otherwise hold the splash forever with no error to report.
|
||||
const Duration _optionalPhaseTimeout = Duration(seconds: 10);
|
||||
|
||||
/// Runs a startup step that the app can survive without.
|
||||
///
|
||||
/// Window chrome, locale selection, crash reporting and the artwork cache are
|
||||
/// all degradable; before #1732 any one of them could veto the whole boot —
|
||||
/// by throwing, or by never completing.
|
||||
Future<void> _optionalGatePhase(
|
||||
StartupPhase phase,
|
||||
Future<void> Function() step, {
|
||||
Duration timeout = _optionalPhaseTimeout,
|
||||
}) async {
|
||||
// Keep a handler on the original future. `timeout` abandons the work rather
|
||||
// than cancelling it, so a late failure would otherwise land as an unhandled
|
||||
// async error long after the gate moved on.
|
||||
final work = Future<void>.sync(step).catchError((Object error, StackTrace stackTrace) {
|
||||
appLogger.w('Optional startup phase "${phase.id}" failed', error: error, stackTrace: stackTrace);
|
||||
});
|
||||
try {
|
||||
await work.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
appLogger.w('Optional startup phase "${phase.id}" did not finish in ${timeout.inSeconds}s; continuing without it');
|
||||
}
|
||||
}
|
||||
|
||||
Future<_StartupDependencies> _initializeApplication() async {
|
||||
final settings = await _gatePhase(StartupPhase.preferences, SettingsService.getInstance);
|
||||
setLoggerLevel(settings.read(SettingsService.enableDebugLogging));
|
||||
|
||||
if (_enableSentry) {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
// Crash reporting is observability: it must never veto the launch it
|
||||
// exists to report on. `Sentry.init` runs its integrations eagerly, so an
|
||||
// integration throwing used to fail the gate before any Plezy code ran.
|
||||
//
|
||||
// Deliberately no `appRunner`. On every non-web platform `Sentry.init`
|
||||
// reduces it to `await appRunner()` after the integrations — error capture
|
||||
// comes from `OnErrorIntegration`/`PlatformDispatcher.onError`, installed
|
||||
// during init either way. Passing the startup work in would make a startup
|
||||
// failure indistinguishable from a Sentry failure, and this guard would
|
||||
// then run the whole gate — migrations, native recovery, database open —
|
||||
// a second time.
|
||||
await _optionalGatePhase(StartupPhase.crashReporting, () async {
|
||||
await SentryFlutter.init((options) {
|
||||
options.dsn = _sentryDsn;
|
||||
options.release = gitCommit.isNotEmpty
|
||||
? 'plezy@${gitCommit.substring(0, 7)}'
|
||||
: 'plezy@${packageInfo.version}+${packageInfo.buildNumber}';
|
||||
options.release = _sentryRelease();
|
||||
if (_sentryEnvironment.isNotEmpty) options.environment = _sentryEnvironment;
|
||||
if (_sentryDist.isNotEmpty) options.dist = _sentryDist;
|
||||
options.tracesSampleRate = 0;
|
||||
@@ -188,16 +242,191 @@ Future<_StartupDependencies> _initializeApplication() async {
|
||||
options.appHangTimeoutInterval = const Duration(seconds: 3);
|
||||
options.beforeSend = _beforeSend;
|
||||
options.beforeBreadcrumb = _beforeBreadcrumb;
|
||||
}, appRunner: initializeStartup);
|
||||
} else {
|
||||
await initializeStartup();
|
||||
});
|
||||
});
|
||||
}
|
||||
return dependencies!;
|
||||
|
||||
AndroidExitDiagnostics.markTelemetryReady();
|
||||
return _initializeStartup(settings);
|
||||
}
|
||||
|
||||
/// Release identifier for Sentry.
|
||||
///
|
||||
/// `PackageInfo.fromPlatform()` reads the executable's Win32 version resource
|
||||
/// and throws `WindowsException`/`ArgumentError` when that read fails (UNC
|
||||
/// launch, antivirus interception). Official builds always define
|
||||
/// `GIT_COMMIT`, so resolve that first and only pay the platform read — inside
|
||||
/// a guard — when there is no commit to use.
|
||||
String _sentryRelease() {
|
||||
if (gitCommit.length >= 7) return 'plezy@${gitCommit.substring(0, 7)}';
|
||||
if (gitCommit.isNotEmpty) return 'plezy@$gitCommit';
|
||||
return 'plezy@unknown';
|
||||
}
|
||||
|
||||
const startupBootstrapProgressKey = Key('startup-bootstrap-progress');
|
||||
const startupBootstrapFailureKey = Key('startup-bootstrap-failure');
|
||||
const startupBootstrapRetryKey = Key('startup-bootstrap-retry');
|
||||
|
||||
/// Human-readable build label for a failure record, primed off the critical
|
||||
/// path by [_primeDiagnosticsVersion].
|
||||
String _diagnosticsVersion = gitCommit.isEmpty ? 'unknown' : gitCommit.substring(0, gitCommit.length.clamp(0, 7));
|
||||
|
||||
/// Resolves the package version for diagnostics without putting it in the gate.
|
||||
///
|
||||
/// `PackageInfo.fromPlatform()` reads the executable's Win32 version resource
|
||||
/// and throws `WindowsException`/`ArgumentError` when that read fails (a UNC
|
||||
/// launch, antivirus interception). It used to run inside the startup gate for
|
||||
/// a value only Sentry consumed; here a failure just leaves the commit label.
|
||||
Future<void> _primeDiagnosticsVersion() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final commit = gitCommit.isEmpty ? '' : ' (${gitCommit.substring(0, gitCommit.length.clamp(0, 7))})';
|
||||
_diagnosticsVersion = '${info.version}+${info.buildNumber}$commit';
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not resolve the package version for diagnostics', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Platform label for a failure record. Deliberately coarse — enough to
|
||||
/// triage a report, never enough to identify a machine.
|
||||
String _diagnosticsPlatform() => '${Platform.operatingSystem} ${Platform.operatingSystemVersion}';
|
||||
|
||||
/// Whether an in-app repair can address [error].
|
||||
///
|
||||
/// Only preference-store damage qualifies. Everything else — a denied
|
||||
/// directory, a locked database, a native library that will not load — needs
|
||||
/// the environment to change, and offering a destructive repair for it would
|
||||
/// be worse than offering nothing.
|
||||
bool _isRepairable(Object error) {
|
||||
final cause = StartupPhaseException.unwrap(error);
|
||||
return cause is CorruptPreferenceStoreException || cause is UnreadableSensitivePreferenceException;
|
||||
}
|
||||
|
||||
/// Default [StartupBootstrap.describeFailure].
|
||||
@visibleForTesting
|
||||
StartupFailureRecord describeStartupFailure(Object error, StackTrace stackTrace) => StartupFailureRecord.fromError(
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
appVersion: _diagnosticsVersion,
|
||||
platform: _diagnosticsPlatform(),
|
||||
repairable: _isRepairable(error),
|
||||
);
|
||||
|
||||
/// Default [StartupBootstrap.reportFailure].
|
||||
@visibleForTesting
|
||||
Future<void> reportStartupFailure(StartupFailureRecord record, Object error, StackTrace stackTrace) async {
|
||||
if (!_enableSentry) return;
|
||||
try {
|
||||
await Sentry.captureException(
|
||||
error,
|
||||
stackTrace: stackTrace,
|
||||
withScope: (scope) {
|
||||
scope.setTag('startup.phase', record.phaseId);
|
||||
scope.level = SentryLevel.fatal;
|
||||
},
|
||||
);
|
||||
} catch (reportError, reportStack) {
|
||||
appLogger.d('Could not report the startup failure', error: reportError, stackTrace: reportStack);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@visibleForTesting
|
||||
Future<bool> 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;
|
||||
|
||||
// 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.
|
||||
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}',
|
||||
confirmText: t.startup.repairConfirm,
|
||||
isDestructive: true,
|
||||
);
|
||||
if (!confirmed || !context.mounted) return false;
|
||||
|
||||
final outcome = unreadableKey != null
|
||||
? await BaseSharedPreferencesService.dropUnreadableCredential(unreadableKey)
|
||||
: await BaseSharedPreferencesService.repairCorruptStore(reopenSafe: reopenSafe);
|
||||
if (!context.mounted) return !outcome.requiresRestart;
|
||||
|
||||
await showRepairOutcomeDialog(context, outcome);
|
||||
return context.mounted && !outcome.requiresRestart;
|
||||
}
|
||||
|
||||
/// Reports a completed repair, including the credential-bearing backup.
|
||||
///
|
||||
/// 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.
|
||||
@visibleForTesting
|
||||
Future<void> showRepairOutcomeDialog(
|
||||
BuildContext context,
|
||||
PrefsRepairOutcome outcome, {
|
||||
// Test seam: the widget-test binding's fake-async zone never completes a
|
||||
// `dart:io` future, so a real delete cannot be driven from a widget test.
|
||||
Future<void> Function(String path) deleteBackup = PrefsRecovery.deleteBackup,
|
||||
}) {
|
||||
final backupPath = outcome.backupPath;
|
||||
// Outside the builder: a `StatefulBuilder` re-runs its closure on every
|
||||
// rebuild, so state declared inside it would reset the moment the rebuild it
|
||||
// triggered arrives — leaving the sensitive path on screen after deletion.
|
||||
var deleted = false;
|
||||
return showScopedDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(outcome.requiresRestart ? t.startup.repairNeedsRestart : t.startup.repairSucceeded),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(outcome.vaultKeySalvaged ? t.startup.repairKeptSignIns : t.startup.repairLostSignIns),
|
||||
if (outcome.sessionsAffected) ...[const SizedBox(height: 8), Text(t.startup.repairLostSessions)],
|
||||
if (backupPath != null && !deleted) ...[
|
||||
const SizedBox(height: 16),
|
||||
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)),
|
||||
const SizedBox(height: 8),
|
||||
DialogActionButton(
|
||||
label: t.startup.deleteBackup,
|
||||
onPressed: () async {
|
||||
await deleteBackup(backupPath);
|
||||
// The barrier can dismiss this dialog while the delete is
|
||||
// in flight; the file is gone either way.
|
||||
if (!dialogContext.mounted) return;
|
||||
setDialogState(() => deleted = true);
|
||||
},
|
||||
),
|
||||
],
|
||||
if (deleted) ...[const SizedBox(height: 8), Text(t.startup.backupDeleted)],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
autofocus: true,
|
||||
isPrimary: true,
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
label: t.common.close,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mounts a Flutter-owned startup frame before invoking the asynchronous
|
||||
/// initialization gate. The generic seam keeps frame ordering, failure, and
|
||||
@@ -210,6 +439,9 @@ class StartupBootstrap<T> extends StatefulWidget {
|
||||
required this.buildApp,
|
||||
this.discard,
|
||||
this.onCommitted,
|
||||
this.describeFailure = describeStartupFailure,
|
||||
this.reportFailure = reportStartupFailure,
|
||||
this.repair = repairStartupStorage,
|
||||
this.lightTheme,
|
||||
this.darkTheme,
|
||||
this.themeMode = material.ThemeMode.system,
|
||||
@@ -219,6 +451,19 @@ class StartupBootstrap<T> extends StatefulWidget {
|
||||
final Widget Function(BuildContext context, T value) buildApp;
|
||||
final FutureOr<void> Function(T value)? discard;
|
||||
final FutureOr<void> Function(T value)? onCommitted;
|
||||
|
||||
/// Reduces a thrown error to the allowlisted record the failure screen,
|
||||
/// the persisted diagnostic and the crash report all share.
|
||||
final StartupFailureRecord Function(Object error, StackTrace stackTrace) describeFailure;
|
||||
|
||||
/// Sends the failure to the crash reporter. The gate catches the error, so
|
||||
/// nothing else will.
|
||||
final Future<void> Function(StartupFailureRecord record, Object error, StackTrace stackTrace) reportFailure;
|
||||
|
||||
/// 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;
|
||||
|
||||
final ThemeData? lightTheme;
|
||||
final ThemeData? darkTheme;
|
||||
final material.ThemeMode themeMode;
|
||||
@@ -229,9 +474,14 @@ class StartupBootstrap<T> extends StatefulWidget {
|
||||
|
||||
class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
T? _value;
|
||||
Object? _error;
|
||||
StartupFailureRecord? _failure;
|
||||
|
||||
/// Retained alongside [_failure] so the repair hook can classify the real
|
||||
/// cause; the record is a redacted allowlist and deliberately cannot.
|
||||
Object? _failureError;
|
||||
bool _completed = false;
|
||||
bool _initializing = false;
|
||||
bool _repairing = false;
|
||||
int _generation = 0;
|
||||
|
||||
@override
|
||||
@@ -248,7 +498,7 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
|
||||
final generation = ++_generation;
|
||||
setState(() {
|
||||
_error = null;
|
||||
_failure = null;
|
||||
_initializing = true;
|
||||
});
|
||||
|
||||
@@ -264,17 +514,52 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
_completed = true;
|
||||
_initializing = false;
|
||||
});
|
||||
// A launch that got through is the first chance to surface a record
|
||||
// written by one that did not. Consuming it takes the file off disk and
|
||||
// holds the contents in memory for Settings › Logs, so the user can
|
||||
// upload the failure that had no other way out (#1732).
|
||||
unawaited(StartupDiagnosticsStore.consumePrevious());
|
||||
unawaited(Future.sync(() => widget.onCommitted?.call(value)));
|
||||
} catch (error, stackTrace) {
|
||||
final failure = widget.describeFailure(error, stackTrace);
|
||||
// `headline` is the sanitised rendering; the raw error is deliberately
|
||||
// not passed, because `FormatException.toString()` embeds an excerpt of
|
||||
// whatever document failed to parse.
|
||||
appLogger.e('Startup initialization failed: ${failure.headline}', stackTrace: stackTrace);
|
||||
// Persist first: this record is the only thing that survives the
|
||||
// process. On Windows there is no log file and no console for a
|
||||
// double-clicked release build.
|
||||
unawaited(StartupDiagnosticsStore.record(failure));
|
||||
// The gate catches this error, so it is a *handled* Dart error that
|
||||
// never reaches PlatformDispatcher.onError. Report it explicitly or the
|
||||
// crash dashboard stays empty for the one failure that hides the app.
|
||||
unawaited(widget.reportFailure(failure, error, stackTrace));
|
||||
if (!mounted || generation != _generation) return;
|
||||
appLogger.e('Startup initialization failed (${error.runtimeType})', stackTrace: stackTrace);
|
||||
setState(() {
|
||||
_error = error;
|
||||
_failure = failure;
|
||||
_failureError = error;
|
||||
_initializing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _repair(StartupFailureRecord failure) async {
|
||||
final repair = widget.repair;
|
||||
final error = _failureError;
|
||||
if (repair == null || error == null || _repairing) return;
|
||||
setState(() => _repairing = true);
|
||||
var repaired = false;
|
||||
try {
|
||||
repaired = 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());
|
||||
}
|
||||
|
||||
Future<void> _discard(T value) async {
|
||||
try {
|
||||
await widget.discard?.call(value);
|
||||
@@ -309,29 +594,15 @@ class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||
}
|
||||
|
||||
Widget _buildBootstrapHome(BuildContext context) {
|
||||
final failure = _failure;
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: _error == null
|
||||
? const CircularProgressIndicator(key: startupBootstrapProgressKey)
|
||||
: Column(
|
||||
key: startupBootstrapFailureKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(Symbols.error_rounded, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.common.error, style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: _initializing ? null : () => unawaited(_initialize()),
|
||||
child: FilledButton(
|
||||
key: startupBootstrapRetryKey,
|
||||
onPressed: _initializing ? null : () => unawaited(_initialize()),
|
||||
child: Text(t.common.retry),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: failure == null
|
||||
? const Center(child: CircularProgressIndicator(key: startupBootstrapProgressKey))
|
||||
: StartupFailureView(
|
||||
failure: failure,
|
||||
busy: _initializing || _repairing,
|
||||
onRetry: () => unawaited(_initialize()),
|
||||
onRepair: failure.repairable && widget.repair != null ? () => _repair(failure) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -394,45 +665,58 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async
|
||||
|
||||
AppDatabase? openedDatabase;
|
||||
try {
|
||||
// Slang builds the base locale eagerly, so `t` already resolves before
|
||||
// this runs; a failure here degrades to English rather than no app.
|
||||
await _optionalGatePhase(StartupPhase.locale, () async {
|
||||
final savedLocale = settings.read(SettingsService.appLocale);
|
||||
await LocaleSettings.setLocale(savedLocale);
|
||||
await initializeDateFormatting(savedLocale.intlLocaleName, null);
|
||||
});
|
||||
markStartupPhase('locale');
|
||||
|
||||
final futures = <Future<void>>[];
|
||||
// Window chrome is cosmetic; losing it costs the custom titlebar and the
|
||||
// remembered size, not the app. It is also the step most exposed to a
|
||||
// stalled platform thread, so it must not sit in the same combined future
|
||||
// as the services the first build genuinely needs.
|
||||
if (PlatformDetector.isDesktopOS()) {
|
||||
if (Platform.isMacOS) {
|
||||
futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar()));
|
||||
} else {
|
||||
futures.add(windowManager.ensureInitialized());
|
||||
}
|
||||
await _optionalGatePhase(StartupPhase.windowManager, () async {
|
||||
await windowManager.ensureInitialized();
|
||||
if (Platform.isMacOS) await MacOSWindowService.setupCustomTitlebar();
|
||||
});
|
||||
}
|
||||
|
||||
// MainApp reads both synchronous facades during its first build.
|
||||
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
||||
futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)));
|
||||
// MainApp reads both synchronous facades during its first build, and both
|
||||
// have a working sync fallback, so a detection failure is not fatal.
|
||||
await _optionalGatePhase(StartupPhase.deviceCapabilities, () async {
|
||||
await (
|
||||
TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)),
|
||||
DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)),
|
||||
).wait;
|
||||
});
|
||||
|
||||
final storageFuture = StorageService.getInstance();
|
||||
futures.add(storageFuture);
|
||||
await Future.wait(futures);
|
||||
final storage = await storageFuture;
|
||||
final storage = await _gatePhase(StartupPhase.storage, StorageService.getInstance);
|
||||
markStartupPhase('platform-services');
|
||||
|
||||
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseOpenStarted);
|
||||
final databaseBootstrap = await openAppDatabaseWithDownloadRecovery(
|
||||
final databaseBootstrap = await _gatePhase(
|
||||
StartupPhase.database,
|
||||
() => openAppDatabaseWithDownloadRecovery(
|
||||
openDatabase: () => AppDatabase.open(isTvos: PlatformDetector.isAppleTV()),
|
||||
recoverNativeDownloads: DownloadManagerService.discardInterruptedNativeDownloadsAfterStorageFailure,
|
||||
storageFullMessage: t.downloads.storageFull,
|
||||
),
|
||||
);
|
||||
openedDatabase = databaseBootstrap.database;
|
||||
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseReady);
|
||||
markStartupPhase('database-recovery');
|
||||
|
||||
DevicePerformance.applyImageCacheBudget();
|
||||
await _optionalGatePhase(StartupPhase.imageCache, () async => DevicePerformance.applyImageCacheBudget());
|
||||
|
||||
// DownloadManagerService reads this singleton synchronously in MainApp's
|
||||
// initState, so its recoverable storage check remains in the explicit gate.
|
||||
await DownloadStorageService.instance.initialize(settings);
|
||||
// initState, but `getArtworkPathSync` already models "not ready" by
|
||||
// returning null and the path re-resolves lazily, so offline artwork is
|
||||
// not a launch requirement.
|
||||
await _optionalGatePhase(StartupPhase.downloadStorage, () => DownloadStorageService.instance.initialize(settings));
|
||||
markStartupPhase('download-storage');
|
||||
|
||||
return _StartupDependencies(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
@@ -19,6 +18,8 @@ import '../../utils/dialogs.dart';
|
||||
import '../../main.dart' show gitCommit;
|
||||
import '../../services/background_work_diagnostics_service.dart';
|
||||
import '../../services/device_performance.dart';
|
||||
import '../../services/log_upload_service.dart';
|
||||
import '../../services/startup_diagnostics.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -26,37 +27,7 @@ import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
|
||||
/// Relay `/logs` accepts 1 MiB. The in-memory buffer intentionally remains
|
||||
/// larger for local viewing and copying; uploads retain the device header and
|
||||
/// newest log lines within this transport contract.
|
||||
const int maxLogUploadBytes = 1 * 1024 * 1024;
|
||||
|
||||
String constrainLogUploadPayload({required String header, required String logs, int maxBytes = maxLogUploadBytes}) {
|
||||
if (maxBytes <= 0) return '';
|
||||
|
||||
final headerBytes = utf8.encode(header);
|
||||
if (headerBytes.length >= maxBytes) {
|
||||
var end = maxBytes;
|
||||
while (end > 0 && end < headerBytes.length && (headerBytes[end] & 0xC0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
return utf8.decode(headerBytes.sublist(0, end));
|
||||
}
|
||||
|
||||
final logBytes = utf8.encode(logs);
|
||||
final availableLogBytes = maxBytes - headerBytes.length;
|
||||
if (logBytes.length <= availableLogBytes) return '$header$logs';
|
||||
|
||||
var start = logBytes.length - availableLogBytes;
|
||||
while (start < logBytes.length && (logBytes[start] & 0xC0) == 0x80) {
|
||||
start++;
|
||||
}
|
||||
final nextLine = logBytes.indexOf(0x0A, start);
|
||||
if (nextLine >= 0 && nextLine + 1 < logBytes.length) {
|
||||
start = nextLine + 1;
|
||||
}
|
||||
return header + utf8.decode(logBytes.sublist(start));
|
||||
}
|
||||
const previousStartupFailureKey = Key('logs-previous-startup-failure');
|
||||
|
||||
class LogsScreen extends StatefulWidget {
|
||||
const LogsScreen({super.key, this.httpClient, this.deviceInfoPlugin});
|
||||
@@ -152,7 +123,20 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
return '$hour:$minute:$second.$millisecond';
|
||||
}
|
||||
|
||||
void _clearLogs() {
|
||||
/// Whether there is anything worth copying, uploading or clearing.
|
||||
///
|
||||
/// A launch that failed the startup gate leaves an empty in-memory buffer —
|
||||
/// that process is gone — but does leave a persisted record. Gating the
|
||||
/// actions on the buffer alone would show that record and then refuse to let
|
||||
/// the user do anything with it, which is the whole point of #1732.
|
||||
bool get _hasDiagnostics => _logs.isNotEmpty || StartupDiagnosticsStore.pending != null;
|
||||
|
||||
Future<void> _clearLogs() async {
|
||||
// The startup record is part of the same diagnostic payload, so clearing
|
||||
// logs drops it too, or it would silently reappear in the next upload.
|
||||
// Awaited before rebuilding so the banner cannot survive the clear.
|
||||
await StartupDiagnosticsStore.clear();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
_logs = [];
|
||||
@@ -161,7 +145,14 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
}
|
||||
|
||||
String _formatAllLogs({int? maxBytes}) {
|
||||
final header = _deviceInfo.isEmpty ? '' : '$_deviceInfo\n---\n';
|
||||
final sections = <String>[
|
||||
if (_deviceInfo.isNotEmpty) _deviceInfo,
|
||||
// A launch that failed the startup gate leaves no in-memory log at all —
|
||||
// the buffer died with that process. This is the only place its record
|
||||
// can reach a maintainer (#1732).
|
||||
?StartupDiagnosticsStore.pending?.describe(),
|
||||
];
|
||||
final header = sections.isEmpty ? '' : '${sections.join('\n\n')}\n---\n';
|
||||
final logs = StringBuffer();
|
||||
var isFirst = true;
|
||||
for (final log in _logs.reversed) {
|
||||
@@ -195,18 +186,11 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
showLoadingDialog(context);
|
||||
|
||||
try {
|
||||
final response = await _httpClient.post(
|
||||
'https://ice.plezy.app/logs',
|
||||
body: logText,
|
||||
headers: {'Content-Type': 'text/plain'},
|
||||
);
|
||||
final id = await uploadDiagnosticText(logText, client: _httpClient);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // dismiss loading
|
||||
|
||||
final data = response.data is String ? jsonDecode(response.data) : response.data;
|
||||
final id = (data as Map<String, dynamic>)['id'] as String;
|
||||
|
||||
unawaited(
|
||||
showScopedDialog<void>(
|
||||
context: context,
|
||||
@@ -330,6 +314,50 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
return spans;
|
||||
}
|
||||
|
||||
/// Banner for a startup failure recorded by an earlier launch.
|
||||
///
|
||||
/// Returns null when there is none, so the caller can splice it in with a
|
||||
/// null-aware element.
|
||||
Widget? _buildPreviousFailureBanner(ThemeData theme) {
|
||||
final failure = StartupDiagnosticsStore.pending;
|
||||
if (failure == null) return null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
key: previousStartupFailureKey,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(8)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AppIcon(Symbols.error_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.startup.previousFailureTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
failure.describe(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -369,22 +397,26 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
FocusableAction(
|
||||
icon: Symbols.upload_rounded,
|
||||
tooltip: t.logs.uploadLogs,
|
||||
onPressed: _logs.isNotEmpty ? _uploadLogs : null,
|
||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.content_copy_rounded,
|
||||
tooltip: t.logs.copyLogs,
|
||||
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
|
||||
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_outline_rounded,
|
||||
tooltip: t.logs.clearLogs,
|
||||
onPressed: _logs.isNotEmpty ? _clearLogs : null,
|
||||
onPressed: _hasDiagnostics ? _clearLogs : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// A launch that failed the startup gate leaves nothing in the
|
||||
// in-memory buffer — that process is gone. Show its record
|
||||
// here, where the user can actually act on it (#1732).
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import 'prefs_recovery.dart';
|
||||
import 'sensitive_prefs.dart';
|
||||
|
||||
/// Base class for services that use SharedPreferences singleton pattern.
|
||||
///
|
||||
/// This class handles the boilerplate for singleton initialization and
|
||||
@@ -81,6 +86,23 @@ abstract class BaseSharedPreferencesService {
|
||||
}
|
||||
|
||||
static Future<SharedPreferencesWithCache> _loadSharedCache() async {
|
||||
// Validate before the plugin reads anything: the desktop backends memoise
|
||||
// the document they parse and never re-read it, so a store rejected only
|
||||
// after the fact could not be repaired in-process (#1732).
|
||||
await PrefsRecovery.assertStoreReadable();
|
||||
try {
|
||||
return await _openSharedCache();
|
||||
} catch (error, stackTrace) {
|
||||
if (!PrefsRecovery.isCorruptStoreError(error)) rethrow;
|
||||
// The preflight accepted this document and the plugin still rejected it,
|
||||
// so it has now cached something we cannot reason about. Repair can
|
||||
// still quarantine the file, but the process has to restart afterwards.
|
||||
appLogger.e('Preference store could not be parsed', error: error, stackTrace: stackTrace);
|
||||
throw CorruptPreferenceStoreException(error, stackTrace, reopenSafe: false);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SharedPreferencesWithCache> _openSharedCache() async {
|
||||
final legacy = await SharedPreferences.getInstance();
|
||||
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
|
||||
legacySharedPreferencesInstance: legacy,
|
||||
@@ -90,6 +112,105 @@ abstract class BaseSharedPreferencesService {
|
||||
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
||||
}
|
||||
|
||||
/// Quarantines an *unparseable* store, opens a fresh one and reseeds every
|
||||
/// credential that could be salvaged.
|
||||
///
|
||||
/// Never call this without an explicit user decision: it resets settings,
|
||||
/// and any credential that could not be salvaged is gone. The salvaged vault
|
||||
/// key is written before this future completes, which is what makes the
|
||||
/// reseed safe — `CredentialVault` memoises the first key it sees, so a
|
||||
/// single read landing before the seed would generate a replacement and
|
||||
/// permanently orphan every token stored in the database. Nothing can read
|
||||
/// preferences until [sharedCache] resolves, so doing the work here closes
|
||||
/// that window entirely.
|
||||
///
|
||||
/// Only valid for [CorruptPreferenceStoreException]. The desktop plugins
|
||||
/// memoise the parsed document in a private `_cachedPreferences` map and
|
||||
/// never re-read it without an explicit reload; that map is only empty here
|
||||
/// because the parse threw before it could be populated. Use
|
||||
/// [dropUnreadableCredential] for a store that parsed but holds one
|
||||
/// 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();
|
||||
|
||||
_resetGeneration++;
|
||||
_initializations.clear();
|
||||
_instances.clear();
|
||||
|
||||
if (!reopenSafe) {
|
||||
// The plugin memoised the bad document before it threw, so reopening
|
||||
// would hand that copy back and the first write would persist it over
|
||||
// the repaired file. Write the salvage straight to disk for the next
|
||||
// process instead, and leave this one's store closed.
|
||||
//
|
||||
// Nothing may write a preference before that restart or the plugin's
|
||||
// stale map would overwrite the seed; the caller keeps the app on the
|
||||
// failure screen precisely so nothing does.
|
||||
final seeded = await PrefsRecovery.seedStore(salvaged);
|
||||
appLogger.w('Preference store quarantined; a restart is required before it can be reopened');
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
vaultKeySalvaged: seeded && salvaged.vaultKey != null,
|
||||
sessionsSalvaged: seeded ? salvaged.sessions.length : 0,
|
||||
sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length,
|
||||
requiresRestart: true,
|
||||
);
|
||||
}
|
||||
|
||||
_cacheFuture = null;
|
||||
final repaired = _cacheLoader().then((cache) async {
|
||||
final vaultKey = salvaged.vaultKey;
|
||||
if (vaultKey != null) await cache.setString(credentialVaultKeyPref, vaultKey);
|
||||
for (final entry in salvaged.sessions.entries) {
|
||||
await cache.setString(entry.key, entry.value);
|
||||
}
|
||||
return cache;
|
||||
});
|
||||
_cacheFuture = repaired;
|
||||
await repaired;
|
||||
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
vaultKeySalvaged: salvaged.vaultKey != null,
|
||||
sessionsSalvaged: salvaged.sessions.length,
|
||||
sessionsLost: salvaged.losses,
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes one credential preference whose stored type is unreadable.
|
||||
///
|
||||
/// The store itself parsed here, so the plugin's `_cachedPreferences` map is
|
||||
/// already populated and a quarantine-and-reopen would hand back that stale
|
||||
/// map and persist the bad value again. Delete through the live cache
|
||||
/// instead: that updates both the in-memory map and the file, and leaves
|
||||
/// every other credential in place.
|
||||
///
|
||||
/// The file is *copied* first, not moved — it is still the app's live store,
|
||||
/// and the copy is the only record of the pre-repair state.
|
||||
static Future<PrefsRepairOutcome> dropUnreadableCredential(String key) async {
|
||||
final backupPath = await PrefsRecovery.backupStore();
|
||||
|
||||
final cache = await sharedCache();
|
||||
await cache.remove(key);
|
||||
|
||||
// Force `onInit` to run again against the repaired store; the cache future
|
||||
// stays as-is because the store was never reopened.
|
||||
_resetGeneration++;
|
||||
_initializations.clear();
|
||||
_instances.clear();
|
||||
|
||||
appLogger.w('Removed unreadable credential preference "$key"');
|
||||
return PrefsRepairOutcome(
|
||||
backupPath: backupPath,
|
||||
// The vault key survives unless it was the unreadable value itself.
|
||||
vaultKeySalvaged: key != credentialVaultKeyPref,
|
||||
sessionsSalvaged: 0,
|
||||
sessionsLost: key == credentialVaultKeyPref ? 0 : 1,
|
||||
settingsReset: false,
|
||||
);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void setCacheLoaderForTesting(Future<SharedPreferencesWithCache> Function() loader) {
|
||||
_cacheFuture = null;
|
||||
@@ -108,13 +229,52 @@ abstract class BaseSharedPreferencesService {
|
||||
_cacheLoader = _loadSharedCache;
|
||||
}
|
||||
|
||||
/// Reads a stored value, tolerating one whose type no longer matches the
|
||||
/// declaration.
|
||||
///
|
||||
/// `SharedPreferencesWithCache.getX` is an `as T?` cast, so a value written
|
||||
/// by an older build, hand-edited, or partially recovered throws `TypeError`
|
||||
/// rather than returning null. A value we cannot read is indistinguishable
|
||||
/// from one that was never written, so drop the key and fall back to the
|
||||
/// declared default instead of letting it propagate — before #1732 a single
|
||||
/// mistyped preference could fail the entire startup gate.
|
||||
///
|
||||
/// Credential slots are exempt: silently dropping one would sign the user
|
||||
/// out with no explanation. Those raise
|
||||
/// [UnreadableSensitivePreferenceException], which the startup gate
|
||||
/// classifies as repairable so the user gets the same consented repair as an
|
||||
/// unparseable store.
|
||||
T? _readTolerant<T>(String key, T? Function() read) {
|
||||
try {
|
||||
return read();
|
||||
} on TypeError catch (error, stackTrace) {
|
||||
if (isSensitivePrefKey(key)) {
|
||||
appLogger.e('Credential preference "$key" is unreadable', error: error, stackTrace: stackTrace);
|
||||
Error.throwWithStackTrace(UnreadableSensitivePreferenceException(key, error), stackTrace);
|
||||
}
|
||||
appLogger.w('Dropping preference "$key" with an unreadable stored type', error: error, stackTrace: stackTrace);
|
||||
unawaited(
|
||||
_cache.remove(key).catchError((Object e, StackTrace s) {
|
||||
appLogger.d('Could not drop unreadable preference "$key"', error: e, stackTrace: s);
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Nullable string read routed through [_readTolerant]. Use instead of
|
||||
/// `prefs.getString(...)` wherever a mistyped stored value must not throw.
|
||||
String? readNullableString(String key) => _readTolerant(key, () => _cache.getString(key));
|
||||
|
||||
/// Typed read helpers — return the stored value or [defaultValue] when missing.
|
||||
bool readBool(String key, {bool defaultValue = false}) => _cache.getBool(key) ?? defaultValue;
|
||||
int readInt(String key, {int defaultValue = 0}) => _cache.getInt(key) ?? defaultValue;
|
||||
double readDouble(String key, {double defaultValue = 0.0}) => _cache.getDouble(key) ?? defaultValue;
|
||||
String readString(String key, {String defaultValue = ''}) => _cache.getString(key) ?? defaultValue;
|
||||
bool readBool(String key, {bool defaultValue = false}) =>
|
||||
_readTolerant(key, () => _cache.getBool(key)) ?? defaultValue;
|
||||
int readInt(String key, {int defaultValue = 0}) => _readTolerant(key, () => _cache.getInt(key)) ?? defaultValue;
|
||||
double readDouble(String key, {double defaultValue = 0.0}) =>
|
||||
_readTolerant(key, () => _cache.getDouble(key)) ?? defaultValue;
|
||||
String readString(String key, {String defaultValue = ''}) => readNullableString(key) ?? defaultValue;
|
||||
List<String> readStringList(String key, {List<String> defaultValue = const []}) =>
|
||||
_cache.getStringList(key) ?? defaultValue;
|
||||
_readTolerant(key, () => _cache.getStringList(key)) ?? defaultValue;
|
||||
|
||||
/// Typed write helpers — symmetric with the read helpers above; use these
|
||||
/// instead of `prefs.setX(...)` so call sites stay terse.
|
||||
@@ -277,7 +437,7 @@ class NullableStringPref extends Pref<String?> {
|
||||
final String? Function(String?)? transform;
|
||||
const NullableStringPref(super.key, {this.transform});
|
||||
@override
|
||||
String? readFrom(BaseSharedPreferencesService svc) => svc.prefs.getString(key);
|
||||
String? readFrom(BaseSharedPreferencesService svc) => svc.readNullableString(key);
|
||||
@override
|
||||
Future<void> writeTo(BaseSharedPreferencesService svc, String? value) async {
|
||||
final normalized = transform == null ? value : transform!(value);
|
||||
@@ -313,7 +473,7 @@ class EnumPref<T extends Enum> extends Pref<T> {
|
||||
T get _default => defaultValueProvider?.call() ?? defaultValue!;
|
||||
@override
|
||||
T readFrom(BaseSharedPreferencesService svc) {
|
||||
final stored = svc.prefs.getString(key);
|
||||
final stored = svc.readNullableString(key);
|
||||
if (stored == null) return _default;
|
||||
return values.firstWhere((v) => v.name == stored, orElse: () => _default);
|
||||
}
|
||||
@@ -332,7 +492,7 @@ class JsonPref<T> extends Pref<T> {
|
||||
|
||||
@override
|
||||
T readFrom(BaseSharedPreferencesService svc) {
|
||||
final s = svc.prefs.getString(key);
|
||||
final s = svc.readNullableString(key);
|
||||
if (s == null) return defaultValue;
|
||||
try {
|
||||
return decode(json.decode(s));
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../utils/media_server_http_client.dart';
|
||||
|
||||
/// Relay endpoint that returns a short, quotable Log ID.
|
||||
const String logUploadEndpoint = 'https://ice.plezy.app/logs';
|
||||
|
||||
/// Relay `/logs` accepts 1 MiB. The in-memory buffer intentionally remains
|
||||
/// larger for local viewing and copying; uploads retain the device header and
|
||||
/// newest log lines within this transport contract.
|
||||
const int maxLogUploadBytes = 1 * 1024 * 1024;
|
||||
|
||||
/// Trims [logs] from the front so `header + logs` fits [maxBytes], keeping the
|
||||
/// header intact and never splitting a UTF-8 sequence or a log line.
|
||||
String constrainLogUploadPayload({required String header, required String logs, int maxBytes = maxLogUploadBytes}) {
|
||||
if (maxBytes <= 0) return '';
|
||||
|
||||
final headerBytes = utf8.encode(header);
|
||||
if (headerBytes.length >= maxBytes) {
|
||||
var end = maxBytes;
|
||||
while (end > 0 && end < headerBytes.length && (headerBytes[end] & 0xC0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
return utf8.decode(headerBytes.sublist(0, end));
|
||||
}
|
||||
|
||||
final logBytes = utf8.encode(logs);
|
||||
final availableLogBytes = maxBytes - headerBytes.length;
|
||||
if (logBytes.length <= availableLogBytes) return '$header$logs';
|
||||
|
||||
var start = logBytes.length - availableLogBytes;
|
||||
while (start < logBytes.length && (logBytes[start] & 0xC0) == 0x80) {
|
||||
start++;
|
||||
}
|
||||
final nextLine = logBytes.indexOf(0x0A, start);
|
||||
if (nextLine >= 0 && nextLine + 1 < logBytes.length) {
|
||||
start = nextLine + 1;
|
||||
}
|
||||
return header + utf8.decode(logBytes.sublist(start));
|
||||
}
|
||||
|
||||
/// Posts [payload] to the log relay and returns the short Log ID.
|
||||
///
|
||||
/// Shared by Settings › Logs and by the startup failure screen, which cannot
|
||||
/// reach Settings because the gate it needs has not completed (#1732).
|
||||
///
|
||||
/// [payload] must already be redacted and allowlisted by the caller; this
|
||||
/// function performs no sanitisation of its own.
|
||||
Future<String> uploadDiagnosticText(String payload, {MediaServerHttpClient? client}) async {
|
||||
final response = await (client ?? httpClient).post(
|
||||
logUploadEndpoint,
|
||||
body: payload,
|
||||
headers: {'Content-Type': 'text/plain'},
|
||||
);
|
||||
final data = response.data is String ? jsonDecode(response.data as String) : response.data;
|
||||
return (data as Map<String, dynamic>)['id'] as String;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../models/seerr/seerr_session.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import 'sensitive_prefs.dart';
|
||||
import 'trackers/tracker_constants.dart';
|
||||
import 'trackers/tracker_session.dart';
|
||||
|
||||
/// File-backed preference store used by the desktop `shared_preferences`
|
||||
/// implementations. Windows and Linux both persist a single flat JSON object
|
||||
/// at `<applicationSupport>/shared_preferences.json`, written with a
|
||||
/// non-atomic `writeAsStringSync` and parsed with an unguarded `json.decode`.
|
||||
/// A crash, power loss or antivirus interception mid-write therefore leaves a
|
||||
/// truncated file that fails every subsequent launch identically.
|
||||
const String prefsStoreFileName = 'shared_preferences.json';
|
||||
|
||||
/// Prefix the legacy `SharedPreferences` API writes into the same store file
|
||||
/// (`shared_preferences_legacy.dart` `_prefix`). Entries only exist under this
|
||||
/// spelling until the legacy-to-async migration has copied them across.
|
||||
const String legacyKeyPrefix = 'flutter.';
|
||||
|
||||
/// Raised when the preference store exists but cannot be parsed.
|
||||
///
|
||||
/// `BaseSharedPreferencesService` converts the platform's raw
|
||||
/// `FormatException`/`TypeError` into this so the startup gate can tell a
|
||||
/// repairable store apart from an inaccessible directory, and offer the user
|
||||
/// an explicit repair instead of a dead app (#1732).
|
||||
///
|
||||
/// The original error is deliberately **not** retained. `json.decode` throws a
|
||||
/// `FormatException` whose `source` is the entire preference document, and
|
||||
/// `FormatException.toString()` prints an excerpt of it around the error
|
||||
/// offset. Anything holding that object could persist, render or upload raw
|
||||
/// credential material — `credential_vault_key_v1`, a tracker refresh token, a
|
||||
/// Seerr cookie — and at this point in startup `LogRedactionManager` has no
|
||||
/// registered values to catch it. Only the cause's type and offset survive,
|
||||
/// both of which are safe by construction.
|
||||
class CorruptPreferenceStoreException implements Exception {
|
||||
CorruptPreferenceStoreException(Object cause, this.causeStackTrace, {this.reopenSafe = true})
|
||||
: causeType = cause.runtimeType.toString(),
|
||||
offset = cause is FormatException ? cause.offset : null;
|
||||
|
||||
/// Runtime type of the discarded cause, e.g. `FormatException`.
|
||||
final String causeType;
|
||||
|
||||
/// Byte offset the parser failed at, when the cause reported one.
|
||||
final int? offset;
|
||||
|
||||
/// Frames only — never contains document contents.
|
||||
final StackTrace causeStackTrace;
|
||||
|
||||
/// Whether the store can be reopened inside this process after a repair.
|
||||
///
|
||||
/// True when [PrefsRecovery.assertStoreReadable] rejected the document
|
||||
/// before either plugin backend touched it, so nothing was memoised. False
|
||||
/// when the preflight passed and the plugin failed anyway: the desktop
|
||||
/// backends cache the parsed document in a private map and never re-read it,
|
||||
/// so reopening would hand back the bad document and write it straight back.
|
||||
/// A repair in that state must be followed by a restart.
|
||||
final bool reopenSafe;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CorruptPreferenceStoreException: the preference store could not be parsed'
|
||||
' ($causeType${offset == null ? '' : ' at offset $offset'})';
|
||||
}
|
||||
|
||||
/// Raised when a credential preference exists but its stored type no longer
|
||||
/// matches the declaration.
|
||||
///
|
||||
/// Ordinary preferences are dropped and defaulted on a type mismatch, but a
|
||||
/// credential cannot be: silently discarding one would sign the user out with
|
||||
/// no explanation. This surfaces instead, so the startup gate can offer the
|
||||
/// same consented repair it offers for an unparseable store (#1732).
|
||||
///
|
||||
/// Only the key name and the cause's type are retained. Key names are not
|
||||
/// secret; values are, and no value ever reaches this object.
|
||||
class UnreadableSensitivePreferenceException implements Exception {
|
||||
UnreadableSensitivePreferenceException(this.key, Object cause) : causeType = cause.runtimeType.toString();
|
||||
|
||||
final String key;
|
||||
final String causeType;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'UnreadableSensitivePreferenceException: credential preference "$key"'
|
||||
' has an unreadable stored type ($causeType)';
|
||||
}
|
||||
|
||||
/// What a repair recovered and what it could not.
|
||||
///
|
||||
/// The distinction is user-facing. A salvaged vault key keeps every
|
||||
/// server/profile signed in, because those tokens live as ciphertext in the
|
||||
/// database rather than in preferences. Tracker and Seerr sessions are stored
|
||||
/// as plaintext preference entries, so they survive only when individually
|
||||
/// salvageable — the vault key says nothing about them.
|
||||
class PrefsRepairOutcome {
|
||||
const PrefsRepairOutcome({
|
||||
required this.backupPath,
|
||||
required this.vaultKeySalvaged,
|
||||
required this.sessionsSalvaged,
|
||||
required this.sessionsLost,
|
||||
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.
|
||||
final String? backupPath;
|
||||
|
||||
/// Whether [credentialVaultKeyPref] was recovered. When false every stored
|
||||
/// server and profile token becomes undecryptable and must be re-acquired.
|
||||
final bool vaultKeySalvaged;
|
||||
|
||||
/// Tracker/Seerr session slots reseeded into the fresh store.
|
||||
final int sessionsSalvaged;
|
||||
|
||||
/// Session slots that were present but unrecoverable.
|
||||
final int sessionsLost;
|
||||
|
||||
/// Whether the user has to reconnect at least one tracker or Seerr instance.
|
||||
bool get sessionsAffected => sessionsLost > 0;
|
||||
|
||||
/// Whether the repair discarded the settings store. False for the surgical
|
||||
/// single-key repair, which leaves every other preference untouched.
|
||||
final bool settingsReset;
|
||||
|
||||
/// Whether the app must restart before the repaired store can be used.
|
||||
/// Set when the plugin had already memoised the bad document.
|
||||
final bool requiresRestart;
|
||||
}
|
||||
|
||||
/// Credentials lifted out of a damaged store, before it is quarantined.
|
||||
class SalvagedPrefsCredentials {
|
||||
const SalvagedPrefsCredentials({required this.vaultKey, required this.sessions, required this.losses});
|
||||
|
||||
static const SalvagedPrefsCredentials empty = SalvagedPrefsCredentials(vaultKey: null, sessions: {}, losses: 0);
|
||||
|
||||
/// Base64 vault key, already validated as exactly 32 raw bytes.
|
||||
final String? vaultKey;
|
||||
|
||||
/// Preference key → encoded session payload, each validated by decoding it
|
||||
/// with the owning store's codec.
|
||||
final Map<String, String> sessions;
|
||||
|
||||
/// Credential slots that were present but could not be decoded.
|
||||
final int losses;
|
||||
}
|
||||
|
||||
/// Recovers a damaged desktop preference store without silently destroying
|
||||
/// credentials.
|
||||
///
|
||||
/// Repair is never automatic. `SettingsService.getInstance()` surfaces
|
||||
/// [CorruptPreferenceStoreException] to the startup gate, the gate offers the
|
||||
/// user an explicit choice that names the real cost, and only an accepted
|
||||
/// choice reaches `BaseSharedPreferencesService.repairCorruptStore`.
|
||||
abstract final class PrefsRecovery {
|
||||
/// Whether [error] means the store is present but unparseable, as opposed to
|
||||
/// missing, inaccessible, or a plugin failure.
|
||||
///
|
||||
/// `shared_preferences_windows` throws `FormatException` from `json.decode`
|
||||
/// on a truncated file and `TypeError` from `Map<String, Object>.from` when
|
||||
/// a decoded value is null.
|
||||
static bool isCorruptStoreError(Object error) => error is FormatException || error is TypeError;
|
||||
|
||||
/// Whether a repair can be attempted on this platform. Only the desktop
|
||||
/// implementations use a single JSON file we can salvage and quarantine;
|
||||
/// Android, iOS and macOS delegate to platform-native stores.
|
||||
static bool get isSupportedPlatform => Platform.isWindows || Platform.isLinux;
|
||||
|
||||
static Future<File> storeFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File(p.join(directory.path, prefsStoreFileName));
|
||||
}
|
||||
|
||||
/// Rejects a damaged desktop store *before* either plugin backend reads it.
|
||||
///
|
||||
/// This ordering is load-bearing. `shared_preferences_windows` assigns the
|
||||
/// decoded document to a private `_cachedPreferences` map and never re-reads
|
||||
/// it, and the map it stores is the lazy result of `Map.cast<String, Object>`
|
||||
/// — so a document containing a null value caches successfully and only
|
||||
/// throws later, when `Map<String, Object>.from` walks it. Detecting that
|
||||
/// after the fact would be useless: the bad document is already memoised, a
|
||||
/// repair could not reopen onto a clean store, and the first write would
|
||||
/// persist the memoised copy straight back over the repaired file.
|
||||
///
|
||||
/// Validating here means the plugin only ever sees a document it can hold.
|
||||
///
|
||||
/// No-op on the platforms that use a native store, and on a missing store —
|
||||
/// a first launch has nothing to validate.
|
||||
static Future<void> assertStoreReadable({File? storeFileOverride}) async {
|
||||
if (storeFileOverride == null && !isSupportedPlatform) return;
|
||||
|
||||
final File file;
|
||||
try {
|
||||
file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) return;
|
||||
} on Object {
|
||||
// Locating or stat-ing the directory is a different failure entirely
|
||||
// (missing/denied application support); let the plugin report it.
|
||||
return;
|
||||
}
|
||||
|
||||
final String raw;
|
||||
try {
|
||||
raw = await file.readAsString();
|
||||
} on FileSystemException {
|
||||
return; // Unreadable rather than invalid; not something a repair fixes.
|
||||
} on FormatException catch (error, stackTrace) {
|
||||
throw CorruptPreferenceStoreException(error, stackTrace); // Not UTF-8.
|
||||
}
|
||||
if (raw.isEmpty) return;
|
||||
|
||||
final Object? decoded;
|
||||
try {
|
||||
decoded = jsonDecode(raw);
|
||||
} on FormatException catch (error, stackTrace) {
|
||||
throw CorruptPreferenceStoreException(error, stackTrace);
|
||||
}
|
||||
|
||||
if (decoded is! Map) {
|
||||
throw CorruptPreferenceStoreException(
|
||||
const FormatException('Preference store is not a JSON object'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
for (final entry in decoded.entries) {
|
||||
if (entry.key is! String || !_isStorableValue(entry.value)) {
|
||||
// The key name is safe to omit and the value must never be quoted, so
|
||||
// the exception deliberately carries neither.
|
||||
throw CorruptPreferenceStoreException(
|
||||
const FormatException('Preference store holds a value of an unsupported type'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors what the desktop backends can hold: the JSON scalars plus a
|
||||
/// string list. A null value is the common real-world offender.
|
||||
static bool _isStorableValue(Object? value) {
|
||||
if (value is bool || value is int || value is double || value is String) return true;
|
||||
return value is List && value.every((element) => element is String);
|
||||
}
|
||||
|
||||
/// Writes a fresh store containing only [salvaged], for the next process.
|
||||
///
|
||||
/// Used when the plugin already memoised the bad document and cannot be
|
||||
/// reopened in-process. The salvage would otherwise be thrown away: the
|
||||
/// quarantined file is gone, nothing reseeds the vault key, and the next
|
||||
/// launch generates a replacement key that orphans every token stored as
|
||||
/// ciphertext in the database.
|
||||
///
|
||||
/// Writes the same flat JSON object the desktop backends read, under the
|
||||
/// unprefixed async key names. Staged through a sibling temporary file and
|
||||
/// renamed into place so a crash mid-write cannot leave a second truncated
|
||||
/// store — the exact failure this whole path exists to recover from.
|
||||
///
|
||||
/// Returns whether the seed landed.
|
||||
static Future<bool> seedStore(SalvagedPrefsCredentials salvaged, {File? storeFileOverride}) async {
|
||||
final vaultKey = salvaged.vaultKey;
|
||||
if (vaultKey == null && salvaged.sessions.isEmpty) return false;
|
||||
final values = <String, Object>{credentialVaultKeyPref: ?vaultKey, ...salvaged.sessions};
|
||||
|
||||
try {
|
||||
final file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.parent.exists()) await file.parent.create(recursive: true);
|
||||
final staged = File('${file.path}.seed');
|
||||
await staged.writeAsString(jsonEncode(values), flush: true);
|
||||
await staged.rename(file.path);
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.e('Could not seed a repaired preference store', error: error, stackTrace: stackTrace);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifts credentials out of the raw bytes of a store that no longer parses.
|
||||
///
|
||||
/// A damaged store is almost always *truncated*, not scrambled, so entries
|
||||
/// near the front usually survive verbatim even when the object as a whole
|
||||
/// has no closing brace. Each value is matched on the wire, unescaped
|
||||
/// individually and validated with its owning codec, so a partially written
|
||||
/// entry is discarded rather than reseeded as garbage.
|
||||
///
|
||||
/// Both key spellings are recognised. The legacy `SharedPreferences` API
|
||||
/// persists into the same file under a [legacyKeyPrefix] prefix, and
|
||||
/// `_loadSharedCache` runs `SharedPreferences.getInstance()` before the
|
||||
/// legacy-to-async migration — so a store damaged mid-migration can hold a
|
||||
/// credential only under its prefixed name. Salvage normalises those onto
|
||||
/// the async key the app actually reads, and an unprefixed entry always wins
|
||||
/// over a prefixed one regardless of their order in the file.
|
||||
@visibleForTesting
|
||||
static SalvagedPrefsCredentials salvage(String raw) {
|
||||
String? vaultKey;
|
||||
var vaultKeyFromLegacy = false;
|
||||
final sessions = <String, String>{};
|
||||
final sessionFromLegacy = <String, bool>{};
|
||||
var losses = 0;
|
||||
|
||||
for (final match in _stringEntryPattern.allMatches(raw)) {
|
||||
final rawKey = _unescape(match.group(1)!);
|
||||
if (rawKey == null) continue;
|
||||
final legacy = rawKey.startsWith(legacyKeyPrefix);
|
||||
final key = legacy ? rawKey.substring(legacyKeyPrefix.length) : rawKey;
|
||||
if (!isSensitivePrefKey(key)) continue;
|
||||
final value = _unescape(match.group(2)!);
|
||||
|
||||
if (key == credentialVaultKeyPref) {
|
||||
if (value == null || !_isVaultKey(value)) {
|
||||
losses++;
|
||||
} else if (vaultKey == null || (vaultKeyFromLegacy && !legacy)) {
|
||||
vaultKey = value;
|
||||
vaultKeyFromLegacy = legacy;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// The legacy Plex slot is an opaque token with no codec to validate
|
||||
// against; a non-empty string is all we can assert.
|
||||
final valid =
|
||||
value != null &&
|
||||
value.isNotEmpty &&
|
||||
(key == legacyPlexTokenPref ? _registerLegacyPlexToken(value) : _validateAndRegisterSession(key, value));
|
||||
if (!valid) {
|
||||
losses++;
|
||||
continue;
|
||||
}
|
||||
if (!sessions.containsKey(key) || ((sessionFromLegacy[key] ?? false) && !legacy)) {
|
||||
sessions[key] = value;
|
||||
sessionFromLegacy[key] = legacy;
|
||||
}
|
||||
// Defence in depth on top of the per-field registration above: catches
|
||||
// the payload being echoed whole.
|
||||
LogRedactionManager.registerCustomValue(value);
|
||||
}
|
||||
|
||||
if (vaultKey != null) {
|
||||
// Register before returning so no later log line, diagnostic or crash
|
||||
// report can echo the key material even if a caller mishandles it.
|
||||
LogRedactionManager.registerCustomValue(vaultKey);
|
||||
}
|
||||
|
||||
return SalvagedPrefsCredentials(vaultKey: vaultKey, sessions: sessions, losses: losses);
|
||||
}
|
||||
|
||||
/// Copies the live store aside without disturbing it.
|
||||
///
|
||||
/// Used by the surgical single-key repair, where the store is still valid
|
||||
/// and stays in place — only the copy records the pre-repair state.
|
||||
static Future<String?> backupStore({File? storeFileOverride}) async {
|
||||
final file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) return null;
|
||||
final backup = File(p.join(file.parent.path, 'shared_preferences.backup-${_stamp()}.json'));
|
||||
try {
|
||||
await file.copy(backup.path);
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
appLogger.w('Could not back up the preference store before repair', error: error, stackTrace: stackTrace);
|
||||
return null;
|
||||
}
|
||||
return backup.path;
|
||||
}
|
||||
|
||||
static String _stamp() => DateTime.now().toUtc().toIso8601String().replaceAll(RegExp(r'[:.]'), '-');
|
||||
|
||||
/// Quarantines the damaged store and returns what was salvaged.
|
||||
///
|
||||
/// 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 {
|
||||
final file = storeFileOverride ?? await storeFile();
|
||||
if (!await file.exists()) {
|
||||
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null);
|
||||
}
|
||||
|
||||
String raw;
|
||||
try {
|
||||
raw = await file.readAsString();
|
||||
} on FileSystemException {
|
||||
rethrow;
|
||||
} on FormatException {
|
||||
// Not valid UTF-8 either; a lossy read still exposes the ASCII-only
|
||||
// credential entries to the salvage pass.
|
||||
raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes());
|
||||
}
|
||||
|
||||
final salvaged = salvage(raw);
|
||||
|
||||
final stamp = _stamp();
|
||||
final backup = File(p.join(file.parent.path, 'shared_preferences.corrupt-$stamp.json'));
|
||||
try {
|
||||
await file.rename(backup.path);
|
||||
} on FileSystemException catch (error, stackTrace) {
|
||||
// Cross-device or locked; copy-then-delete keeps the bytes rather than
|
||||
// failing the repair outright.
|
||||
appLogger.w(
|
||||
'Preference store quarantine could not rename; copying instead',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
await file.copy(backup.path);
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
appLogger.w(
|
||||
'Quarantined a corrupt preference store'
|
||||
' (vault key salvaged: ${salvaged.vaultKey != null},'
|
||||
' sessions salvaged: ${salvaged.sessions.length}, lost: ${salvaged.losses})',
|
||||
);
|
||||
return (salvaged: salvaged, backupPath: backup.path);
|
||||
}
|
||||
|
||||
/// Deletes a quarantined store so the user is not left holding a
|
||||
/// credential-bearing file indefinitely.
|
||||
static Future<void> deleteBackup(String path) async {
|
||||
final file = File(path);
|
||||
if (await file.exists()) await file.delete();
|
||||
}
|
||||
|
||||
// A `"key": "value"` pair with JSON-escaped halves. Deliberately tolerant of
|
||||
// the surrounding object being unterminated.
|
||||
static final RegExp _stringEntryPattern = RegExp(r'"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"');
|
||||
|
||||
/// The vault generates exactly 32 random bytes; anything else would make
|
||||
/// `AesGcm.with256bits()` throw on first use, which is worse than no key.
|
||||
static bool _isVaultKey(String value) {
|
||||
if (value.isEmpty) return false;
|
||||
try {
|
||||
return base64Decode(value).length == 32;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The legacy Plex slot is an opaque bearer token. Registering it as a token
|
||||
/// also covers its URL-encoded form.
|
||||
static bool _registerLegacyPlexToken(String value) {
|
||||
LogRedactionManager.registerToken(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Validates a session payload with its owning codec and registers every
|
||||
/// secret it carries individually.
|
||||
///
|
||||
/// Registering only the encoded payload would redact it just when it appears
|
||||
/// verbatim; a bare access token, refresh token or session cookie quoted on
|
||||
/// its own would still leak. The decode step already hands us the fields, so
|
||||
/// harvest them while they are in scope.
|
||||
static bool _validateAndRegisterSession(String key, String encoded) {
|
||||
try {
|
||||
if (isSeerrSessionPrefKey(key)) {
|
||||
final session = SeerrSession.decode(encoded);
|
||||
LogRedactionManager.registerToken(session.cookie);
|
||||
// `secret` is CredentialVault ciphertext at rest; the plaintext is not
|
||||
// available here, so register the stored form.
|
||||
LogRedactionManager.registerCustomValue(session.secret);
|
||||
LogRedactionManager.registerServerUrl(session.baseUrl);
|
||||
return true;
|
||||
}
|
||||
final base = profileScopedCredentialBaseKey(key);
|
||||
final service = TrackerService.values.where((s) => base == '${s.name}_session').firstOrNull;
|
||||
if (service == null) return false;
|
||||
final session = TrackerSession.decode(encoded, service: service);
|
||||
LogRedactionManager.registerToken(session.accessToken);
|
||||
LogRedactionManager.registerToken(session.refreshToken);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static String? _unescape(String rawInner) {
|
||||
try {
|
||||
return jsonDecode('"$rawInner"') as String;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/// Preference keys whose stored values are credentials.
|
||||
///
|
||||
/// `shared_preferences` is the most credential-dense artifact in a Plezy
|
||||
/// installation. On the desktop platforms it is a single plaintext JSON file
|
||||
/// next to the database, and it holds:
|
||||
///
|
||||
/// * [credentialVaultKeyPref] — the AES-256 key that `CredentialVault` uses to
|
||||
/// protect every server/profile token stored in the Drift
|
||||
/// `connections.config_json` and `profile_connections.user_token` columns.
|
||||
/// Losing it orphans every one of those ciphertexts permanently.
|
||||
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
|
||||
/// verbatim, so raw OAuth `access_token`/`refresh_token` pairs for MAL,
|
||||
/// AniList, Simkl and Trakt live here in plaintext.
|
||||
/// * Seerr sessions — `SeerrSessionStore` persists a raw `connect.sid` cookie
|
||||
/// alongside a vault-protected password.
|
||||
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
|
||||
/// drained by the connection migration but can linger on old installs.
|
||||
///
|
||||
/// Two subsystems consult this list, both added for #1732:
|
||||
///
|
||||
/// * the tolerant preference reads in `BaseSharedPreferencesService` must never
|
||||
/// silently drop one of these keys — an unreadable credential has to surface
|
||||
/// as an explicit repair prompt, not as a silent re-authentication;
|
||||
/// * the corrupt-store repair in `PrefsRecovery` salvages exactly these keys
|
||||
/// out of a damaged store before quarantining it.
|
||||
///
|
||||
/// Keep this list exhaustive. A credential slot that is missing here is
|
||||
/// silently dropped on a type mismatch and silently lost on a repair.
|
||||
///
|
||||
/// This lives apart from `CredentialVault`, `TrackerAccountStore` and
|
||||
/// `SeerrSessionStore` so `BaseSharedPreferencesService` can depend on it
|
||||
/// without an import cycle.
|
||||
library;
|
||||
|
||||
/// Key holding the base64 `CredentialVault` AES-256 key.
|
||||
const String credentialVaultKeyPref = 'credential_vault_key_v1';
|
||||
|
||||
/// Legacy single-slot Plex token, superseded by the connection registry.
|
||||
const String legacyPlexTokenPref = 'plex_token';
|
||||
|
||||
/// Unscoped base keys used by `TrackerAccountStore`, one per tracker service.
|
||||
const List<String> trackerSessionBaseKeys = <String>[
|
||||
'mal_session',
|
||||
'anilist_session',
|
||||
'simkl_session',
|
||||
'trakt_session',
|
||||
];
|
||||
|
||||
/// Unscoped base key used by `SeerrSessionStore`.
|
||||
const String seerrSessionBaseKey = 'seerr_session';
|
||||
|
||||
/// Every credential slot that is profile-scoped through `profileScopedPrefsKey`,
|
||||
/// so a stored key is either the bare base key or `user_{scope}_{baseKey}`.
|
||||
const List<String> profileScopedCredentialBaseKeys = <String>[...trackerSessionBaseKeys, seerrSessionBaseKey];
|
||||
|
||||
final RegExp _profileScopedCredentialPattern = RegExp(
|
||||
'^(?:user_.+_)?(?:${profileScopedCredentialBaseKeys.join('|')})\$',
|
||||
);
|
||||
|
||||
/// The unscoped base key [key] resolves to, or null when [key] is not a
|
||||
/// profile-scoped credential slot.
|
||||
String? profileScopedCredentialBaseKey(String key) {
|
||||
if (!_profileScopedCredentialPattern.hasMatch(key)) return null;
|
||||
for (final base in profileScopedCredentialBaseKeys) {
|
||||
if (key == base || key.endsWith('_$base')) return base;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Whether [key] is a profile-scoped or global tracker session slot.
|
||||
bool isTrackerSessionPrefKey(String key) => trackerSessionBaseKeys.contains(profileScopedCredentialBaseKey(key));
|
||||
|
||||
/// Whether [key] is a profile-scoped or global Seerr session slot.
|
||||
bool isSeerrSessionPrefKey(String key) => profileScopedCredentialBaseKey(key) == seerrSessionBaseKey;
|
||||
|
||||
/// Whether [key] holds a credential and must never be dropped or exported
|
||||
/// without an explicit, informed user decision.
|
||||
bool isSensitivePrefKey(String key) =>
|
||||
key == credentialVaultKeyPref || key == legacyPlexTokenPref || profileScopedCredentialBaseKey(key) != null;
|
||||
@@ -599,7 +599,7 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
const legacyRecentRoomsKey = 'watch_together_recent_rooms';
|
||||
await prefs.remove(legacyRecentRoomsKey);
|
||||
|
||||
final storedRelay = prefs.getString(customRelayUrl.key);
|
||||
final storedRelay = readNullableString(customRelayUrl.key);
|
||||
if (storedRelay == null) return;
|
||||
final endpoint = WatchTogetherRelayEndpoint.tryParseCustom(storedRelay);
|
||||
if (endpoint == null) {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
|
||||
/// Named steps of the startup gate.
|
||||
///
|
||||
/// The gate used to report a bare `error.runtimeType` with no indication of
|
||||
/// which step failed, and `Future.wait` discarded every error but the first,
|
||||
/// so even that was ambiguous between four concurrent steps (#1732). Every
|
||||
/// step now carries a stable identifier that reaches the failure screen, the
|
||||
/// log, the persisted record and Sentry.
|
||||
enum StartupPhase {
|
||||
preferences('preferences'),
|
||||
crashReporting('crash-reporting'),
|
||||
locale('locale'),
|
||||
windowManager('window-manager'),
|
||||
deviceCapabilities('device-capabilities'),
|
||||
storage('storage'),
|
||||
database('database'),
|
||||
imageCache('image-cache'),
|
||||
downloadStorage('download-storage');
|
||||
|
||||
const StartupPhase(this.id);
|
||||
|
||||
/// Stable wire/log identifier. Do not rename: persisted records and Sentry
|
||||
/// tags are matched on it.
|
||||
final String id;
|
||||
|
||||
static StartupPhase? fromId(String? id) =>
|
||||
id == null ? null : StartupPhase.values.where((phase) => phase.id == id).firstOrNull;
|
||||
}
|
||||
|
||||
/// Tags a startup failure with the gate phase it came from.
|
||||
///
|
||||
/// Transparent by design: [cause] is the original error, so existing
|
||||
/// classification (`isStorageFullError`, corrupt-store detection) keeps working
|
||||
/// on `exception.cause` and the reported runtime type stays the real one.
|
||||
class StartupPhaseException implements Exception {
|
||||
const StartupPhaseException(this.phase, this.cause);
|
||||
|
||||
final StartupPhase phase;
|
||||
final Object cause;
|
||||
|
||||
/// Unwraps nested wrappers so callers always classify the real error.
|
||||
static Object unwrap(Object error) {
|
||||
var current = error;
|
||||
while (current is StartupPhaseException) {
|
||||
current = current.cause;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
static StartupPhase? phaseOf(Object error) => error is StartupPhaseException ? error.phase : null;
|
||||
|
||||
@override
|
||||
String toString() => 'StartupPhaseException(${phase.id}): ${StartupFailureRecord.describeErrorSafely(cause)}';
|
||||
}
|
||||
|
||||
/// A startup-gate failure, reduced to an allowlist of fields that are safe to
|
||||
/// show, copy, persist and upload.
|
||||
///
|
||||
/// Redaction is defence in depth here, not the mechanism: nothing derived from
|
||||
/// preference contents, database rows or file bytes is ever placed in a
|
||||
/// record. That matters because `LogRedactionManager`'s registered-value set is
|
||||
/// seeded by `StorageService.onInit`, which runs *inside* the gate — a failure
|
||||
/// at or before that step leaves only the pattern matcher active.
|
||||
class StartupFailureRecord {
|
||||
StartupFailureRecord({
|
||||
required this.phase,
|
||||
required this.errorType,
|
||||
required String message,
|
||||
required String? stackTrace,
|
||||
required this.timestamp,
|
||||
required this.appVersion,
|
||||
required this.platform,
|
||||
this.repairable = false,
|
||||
}) : message = LogRedactionManager.redact(message),
|
||||
stackTrace = stackTrace == null ? null : LogRedactionManager.redact(stackTrace);
|
||||
|
||||
/// Builds a record from a thrown [error].
|
||||
///
|
||||
/// [StartupPhaseException] wrappers are unwrapped so the recorded type and
|
||||
/// message describe the real failure, and [phase] defaults to the one the
|
||||
/// wrapper carries.
|
||||
factory StartupFailureRecord.fromError({
|
||||
required Object error,
|
||||
required StackTrace? stackTrace,
|
||||
required String appVersion,
|
||||
required String platform,
|
||||
StartupPhase? phase,
|
||||
bool repairable = false,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
final cause = StartupPhaseException.unwrap(error);
|
||||
return StartupFailureRecord(
|
||||
phase: phase ?? StartupPhaseException.phaseOf(error),
|
||||
errorType: cause.runtimeType.toString(),
|
||||
message: describeErrorSafely(cause),
|
||||
stackTrace: stackTrace?.toString(),
|
||||
timestamp: timestamp ?? DateTime.now(),
|
||||
appVersion: appVersion,
|
||||
platform: platform,
|
||||
repairable: repairable,
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders [error] without the payload some exception types embed.
|
||||
///
|
||||
/// `FormatException.toString()` prints an excerpt of `source` around
|
||||
/// `offset`, and during startup that source is very often a document we must
|
||||
/// never surface: the preference store holds the credential-vault key,
|
||||
/// tracker refresh tokens and Seerr cookies in plaintext. Field-pattern
|
||||
/// redaction cannot be relied on here because the registered-value set is
|
||||
/// seeded inside the gate that just failed. Keep the parser's own message
|
||||
/// and offset, drop the excerpt.
|
||||
@visibleForTesting
|
||||
static String describeErrorSafely(Object error) {
|
||||
final cause = StartupPhaseException.unwrap(error);
|
||||
if (cause is! FormatException) return cause.toString();
|
||||
final offset = cause.offset;
|
||||
final message = cause.message.isEmpty ? 'FormatException' : cause.message;
|
||||
return offset == null ? message : '$message (at offset $offset)';
|
||||
}
|
||||
|
||||
final StartupPhase? phase;
|
||||
final String errorType;
|
||||
|
||||
/// Already redacted by the constructor.
|
||||
final String message;
|
||||
|
||||
/// Already redacted by the constructor.
|
||||
final String? stackTrace;
|
||||
|
||||
final DateTime timestamp;
|
||||
final String appVersion;
|
||||
final String platform;
|
||||
|
||||
/// Whether the gate can offer an in-app repair for this failure.
|
||||
final bool repairable;
|
||||
|
||||
String get phaseId => phase?.id ?? 'unknown';
|
||||
|
||||
/// One-line summary for the failure screen and the log.
|
||||
String get headline => '[$phaseId] $errorType: $message';
|
||||
|
||||
/// Full plain-text block for the clipboard and the diagnostics upload.
|
||||
String describe() {
|
||||
final buffer = StringBuffer()
|
||||
..writeln('Plezy startup failure')
|
||||
..writeln('Version: $appVersion')
|
||||
..writeln('Platform: $platform')
|
||||
..writeln('When: ${timestamp.toUtc().toIso8601String()}')
|
||||
..writeln('Phase: $phaseId')
|
||||
..writeln('Error: $errorType')
|
||||
..writeln('Message: $message');
|
||||
final stack = stackTrace;
|
||||
if (stack != null && stack.isNotEmpty) {
|
||||
buffer
|
||||
..writeln('Stack trace:')
|
||||
..writeln(stack);
|
||||
}
|
||||
return buffer.toString().trimRight();
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'phase': phase?.id,
|
||||
'errorType': errorType,
|
||||
'message': message,
|
||||
'stackTrace': stackTrace,
|
||||
'timestamp': timestamp.toUtc().toIso8601String(),
|
||||
'appVersion': appVersion,
|
||||
'platform': platform,
|
||||
'repairable': repairable,
|
||||
};
|
||||
|
||||
static StartupFailureRecord? fromJson(Map<String, Object?> json) {
|
||||
final message = json['message'];
|
||||
final errorType = json['errorType'];
|
||||
final timestamp = DateTime.tryParse(json['timestamp'] as String? ?? '');
|
||||
if (message is! String || errorType is! String || timestamp == null) return null;
|
||||
return StartupFailureRecord(
|
||||
phase: StartupPhase.fromId(json['phase'] as String?),
|
||||
errorType: errorType,
|
||||
message: message,
|
||||
stackTrace: json['stackTrace'] as String?,
|
||||
timestamp: timestamp,
|
||||
appVersion: json['appVersion'] as String? ?? 'unknown',
|
||||
platform: json['platform'] as String? ?? 'unknown',
|
||||
repairable: json['repairable'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the most recent startup-gate failure so it survives the process.
|
||||
///
|
||||
/// A failing launch has no other egress: the log buffer is in memory only, a
|
||||
/// GUI-launched Windows release build has no console, and the in-app log
|
||||
/// viewer sits behind the gate that just failed. Writing one small record next
|
||||
/// to the database lets the next *successful* launch surface it in
|
||||
/// Settings › Logs, where the user can upload it (#1732).
|
||||
///
|
||||
/// The record is an allowlist of already-redacted fields; raw store contents
|
||||
/// never reach it.
|
||||
abstract final class StartupDiagnosticsStore {
|
||||
static const String fileName = 'startup_failure.json';
|
||||
|
||||
@visibleForTesting
|
||||
static Directory? debugDirectoryOverride;
|
||||
|
||||
static StartupFailureRecord? _pending;
|
||||
|
||||
/// Record observed during this launch, if any. Set both when a failure is
|
||||
/// recorded and when one written by an earlier launch is consumed.
|
||||
static StartupFailureRecord? get pending => _pending;
|
||||
|
||||
static Future<File?> _file() async {
|
||||
try {
|
||||
final directory = debugDirectoryOverride ?? await getApplicationSupportDirectory();
|
||||
return File(p.join(directory.path, fileName));
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Startup diagnostics location unavailable', error: error, stackTrace: stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort write. A diagnostics failure must never worsen the failure it
|
||||
/// is describing, so every error here is logged and swallowed.
|
||||
static Future<void> record(StartupFailureRecord failure) async {
|
||||
_pending = failure;
|
||||
try {
|
||||
final file = await _file();
|
||||
if (file == null) return;
|
||||
if (!await file.parent.exists()) await file.parent.create(recursive: true);
|
||||
await file.writeAsString(jsonEncode(failure.toJson()), flush: true);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not persist the startup failure record', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads and deletes a record written by an earlier launch.
|
||||
///
|
||||
/// Deleting on read stops one stale failure from following the user forever;
|
||||
/// the value stays in [pending] for the rest of the session so the logs
|
||||
/// screen can still show it after the user navigates away and back.
|
||||
static Future<StartupFailureRecord?> consumePrevious() async {
|
||||
try {
|
||||
final file = await _file();
|
||||
if (file == null || !await file.exists()) return null;
|
||||
final raw = await file.readAsString();
|
||||
await file.delete();
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return null;
|
||||
final record = StartupFailureRecord.fromJson(decoded.cast<String, Object?>());
|
||||
if (record != null) _pending = record;
|
||||
return record;
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not read a previous startup failure record', error: error, stackTrace: stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops a persisted record without surfacing it in [pending].
|
||||
static Future<void> clear() async {
|
||||
_pending = null;
|
||||
try {
|
||||
final file = await _file();
|
||||
if (file != null && await file.exists()) await file.delete();
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.d('Could not clear the startup failure record', error: error, stackTrace: stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void resetForTesting() {
|
||||
_pending = null;
|
||||
debugDirectoryOverride = null;
|
||||
}
|
||||
|
||||
/// Seeds [pending] without touching disk, for widget tests. The widget-test
|
||||
/// binding runs in a fake-async zone where a `dart:io` future never
|
||||
/// completes, so [record] cannot be awaited from one.
|
||||
@visibleForTesting
|
||||
static void setPendingForTesting(StartupFailureRecord? failure) => _pending = failure;
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration<String>(
|
||||
baseKey,
|
||||
prefix: _userPrefix,
|
||||
read: prefs.getString,
|
||||
read: readNullableString,
|
||||
write: prefs.setString,
|
||||
);
|
||||
|
||||
@@ -167,7 +167,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
'Only ConnectionBootstrap.migrateLegacyPlexAccount may use this.',
|
||||
)
|
||||
String? getPlexToken() {
|
||||
return prefs.getString(_keyPlexToken);
|
||||
return readNullableString(_keyPlexToken);
|
||||
}
|
||||
|
||||
/// Drop the legacy `plex_token` slot. Called by
|
||||
@@ -257,14 +257,9 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
}
|
||||
|
||||
String? getLibraryTab(String sectionId) {
|
||||
final key = '$_userPrefix$_prefixLibraryTab$sectionId';
|
||||
// Handle migration from old int storage: try string first, fall back to removing stale int
|
||||
try {
|
||||
return prefs.getString(key);
|
||||
} catch (_) {
|
||||
prefs.remove(key);
|
||||
return null;
|
||||
}
|
||||
// Older builds stored this as an int; `readNullableString` drops a value it
|
||||
// cannot read and falls back to null, which is the correct behaviour here.
|
||||
return readNullableString('$_userPrefix$_prefixLibraryTab$sectionId');
|
||||
}
|
||||
|
||||
// Hidden Libraries (stored as JSON array of library section IDs)
|
||||
@@ -285,7 +280,7 @@ class StorageService extends BaseSharedPreferencesService {
|
||||
_readScopedWithLegacyMigration<String>(
|
||||
_keyHiddenLibraries,
|
||||
prefix: _userPrefixForProfileId(profileId),
|
||||
read: prefs.getString,
|
||||
read: readNullableString,
|
||||
write: prefs.setString,
|
||||
// Only the active profile may adopt the legacy unscoped value. Otherwise
|
||||
// merely opening another profile's scoped provider could steal legacy
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/log_upload_service.dart';
|
||||
import '../services/startup_diagnostics.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
|
||||
const startupBootstrapFailureKey = Key('startup-bootstrap-failure');
|
||||
const startupBootstrapRetryKey = Key('startup-bootstrap-retry');
|
||||
const startupFailureDetailsKey = Key('startup-failure-details');
|
||||
const startupFailureCopyKey = Key('startup-failure-copy');
|
||||
const startupFailureUploadKey = Key('startup-failure-upload');
|
||||
const startupFailureRepairKey = Key('startup-failure-repair');
|
||||
|
||||
/// Everything the startup gate can show when initialization fails.
|
||||
///
|
||||
/// Before #1732 this was an icon, the word "Error" and a Retry button: the
|
||||
/// error object was captured and then discarded, and the only log viewer sat
|
||||
/// behind the gate that had just failed. On Windows that left literally no way
|
||||
/// to find out what went wrong — no log file, and no console for a
|
||||
/// double-clicked release build.
|
||||
///
|
||||
/// Everything rendered here comes from [StartupFailureRecord], which is an
|
||||
/// 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});
|
||||
|
||||
final StartupFailureRecord failure;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
/// Runs the consented storage repair. Null when the failure is not one an
|
||||
/// in-app repair can address.
|
||||
final Future<void> Function()? onRepair;
|
||||
|
||||
final bool busy;
|
||||
|
||||
@override
|
||||
State<StartupFailureView> createState() => _StartupFailureViewState();
|
||||
}
|
||||
|
||||
class _StartupFailureViewState extends State<StartupFailureView> {
|
||||
late final FocusNode _retryFocusNode = FocusNode(debugLabel: 'startup-failure-retry');
|
||||
bool _detailsExpanded = false;
|
||||
bool _uploading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_retryFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _copyDetails() {
|
||||
Clipboard.setData(ClipboardData(text: widget.failure.describe()));
|
||||
showSuccessSnackBar(context, t.startup.detailsCopied);
|
||||
}
|
||||
|
||||
Future<void> _uploadDetails() async {
|
||||
setState(() => _uploading = true);
|
||||
try {
|
||||
final id = await uploadDiagnosticText(widget.failure.describe());
|
||||
if (!mounted) return;
|
||||
await showScopedDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(t.messages.logsUploaded),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${t.messages.logId}:'),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
label: t.common.close,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
appLogger.w('Startup diagnostics upload failed', error: error, stackTrace: stackTrace);
|
||||
if (mounted) showErrorSnackBar(context, t.messages.logsUploadFailed);
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final failure = widget.failure;
|
||||
final enabled = !widget.busy && !_uploading;
|
||||
final repair = widget.onRepair;
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Column(
|
||||
key: startupBootstrapFailureKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(Symbols.error_rounded, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(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),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${t.startup.phaseLabel}: ${failure.phaseId} · ${failure.errorType}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildDetails(theme, failure),
|
||||
const SizedBox(height: 20),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
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)
|
||||
FocusableButton(
|
||||
onPressed: enabled ? () => repair() : null,
|
||||
child: FilledButton.tonal(
|
||||
key: startupFailureRepairKey,
|
||||
onPressed: enabled ? () => repair() : null,
|
||||
child: Text(t.startup.repairStorage),
|
||||
),
|
||||
),
|
||||
FocusableButton(
|
||||
onPressed: enabled ? _copyDetails : null,
|
||||
child: OutlinedButton(
|
||||
key: startupFailureCopyKey,
|
||||
onPressed: enabled ? _copyDetails : null,
|
||||
child: Text(t.startup.copyDetails),
|
||||
),
|
||||
),
|
||||
FocusableButton(
|
||||
onPressed: enabled ? () => _uploadDetails() : null,
|
||||
child: OutlinedButton(
|
||||
key: startupFailureUploadKey,
|
||||
onPressed: enabled ? () => _uploadDetails() : null,
|
||||
child: Text(t.startup.uploadDetails),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDetails(ThemeData theme, StartupFailureRecord failure) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FocusableButton(
|
||||
onPressed: () => setState(() => _detailsExpanded = !_detailsExpanded),
|
||||
child: TextButton(
|
||||
onPressed: () => setState(() => _detailsExpanded = !_detailsExpanded),
|
||||
child: Text(_detailsExpanded ? t.startup.hideDetails : t.startup.showDetails),
|
||||
),
|
||||
),
|
||||
if (_detailsExpanded)
|
||||
Container(
|
||||
key: startupFailureDetailsKey,
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: SelectableText(
|
||||
failure.describe(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace', fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,105 @@ class _AppDatabaseTestSuite {
|
||||
);
|
||||
});
|
||||
|
||||
test('desktop open removes orphaned sidecars when the main database is absent', () async {
|
||||
await db.close();
|
||||
resetSharedPreferencesForTest();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_db_orphaned_sidecars_test_');
|
||||
final file = File('${tempDir.path}/plezy_downloads.db');
|
||||
final wal = File('${file.path}-wal');
|
||||
final shm = File('${file.path}-shm');
|
||||
AppDatabase? opened;
|
||||
|
||||
try {
|
||||
await wal.writeAsBytes([1, 2, 3]);
|
||||
await shm.writeAsBytes([4, 5, 6]);
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
|
||||
final bootstrap = await AppDatabase.open(
|
||||
isTvos: false,
|
||||
databaseFile: file,
|
||||
preferences: prefs,
|
||||
executorFactory: (_) => NativeDatabase.memory(),
|
||||
);
|
||||
opened = bootstrap.database;
|
||||
|
||||
expect(await wal.exists(), isFalse);
|
||||
expect(await shm.exists(), isFalse);
|
||||
} finally {
|
||||
await opened?.close();
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
}
|
||||
});
|
||||
|
||||
test('desktop open preserves sidecars when the main database exists', () async {
|
||||
await db.close();
|
||||
resetSharedPreferencesForTest();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_db_live_sidecars_test_');
|
||||
final file = File('${tempDir.path}/plezy_downloads.db');
|
||||
final wal = File('${file.path}-wal');
|
||||
final shm = File('${file.path}-shm');
|
||||
AppDatabase? opened;
|
||||
|
||||
try {
|
||||
await file.writeAsBytes([0x50, 0x4c, 0x45, 0x5a, 0x59]);
|
||||
await wal.writeAsBytes([1, 2, 3]);
|
||||
await shm.writeAsBytes([4, 5, 6]);
|
||||
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||
|
||||
final bootstrap = await AppDatabase.open(
|
||||
isTvos: false,
|
||||
databaseFile: file,
|
||||
preferences: prefs,
|
||||
executorFactory: (_) => NativeDatabase.memory(),
|
||||
);
|
||||
opened = bootstrap.database;
|
||||
|
||||
expect(await wal.readAsBytes(), [1, 2, 3]);
|
||||
expect(await shm.readAsBytes(), [4, 5, 6]);
|
||||
} finally {
|
||||
await opened?.close();
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
}
|
||||
});
|
||||
|
||||
test('retried v14 migration tolerates an existing connections table', () async {
|
||||
await db.close();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_db_existing_connections_test_');
|
||||
final file = File('${tempDir.path}/plezy_downloads.db');
|
||||
AppDatabase? seeded;
|
||||
AppDatabase? reopened;
|
||||
|
||||
try {
|
||||
seeded = AppDatabase.forTesting(NativeDatabase(file));
|
||||
await seeded.select(seeded.apiCache).get();
|
||||
final connectionTableSql =
|
||||
(await seeded
|
||||
.customSelect("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'connections'")
|
||||
.getSingle())
|
||||
.read<String>('sql');
|
||||
await _createSchemaV13Fixture(seeded);
|
||||
await seeded.customStatement(connectionTableSql);
|
||||
await seeded.close();
|
||||
seeded = null;
|
||||
|
||||
reopened = AppDatabase.forTesting(NativeDatabase(file));
|
||||
expect(await reopened.select(reopened.connections).get(), isEmpty);
|
||||
} finally {
|
||||
await reopened?.close();
|
||||
await seeded?.close();
|
||||
if (await tempDir.exists()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
}
|
||||
});
|
||||
|
||||
test('retried v14 migration tolerates existing indices', () async {
|
||||
await db.close();
|
||||
final tempDir = await Directory.systemTemp.createTemp('plezy_db_migration_test_');
|
||||
|
||||
@@ -7,9 +7,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:plezy/focus/focusable_action_bar.dart';
|
||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/screens/settings/logs_screen.dart';
|
||||
import 'package:plezy/services/log_upload_service.dart';
|
||||
import 'package:plezy/services/startup_diagnostics.dart';
|
||||
import 'package:plezy/utils/app_logger.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
@@ -157,4 +160,70 @@ void main() {
|
||||
expect(clipboardText, capability);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
group('previous startup failure', () {
|
||||
late StartupFailureRecord record;
|
||||
|
||||
Future<void> pumpLogs(WidgetTester tester) async {
|
||||
PackageInfo.setMockInitialValues(
|
||||
appName: 'Plezy',
|
||||
packageName: 'com.plezy.test',
|
||||
version: '2.11.0',
|
||||
buildNumber: '124',
|
||||
buildSignature: '',
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: InputModeTracker(child: MaterialApp(home: const LogsScreen())),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
StartupDiagnosticsStore.resetForTesting();
|
||||
record = StartupFailureRecord.fromError(
|
||||
phase: StartupPhase.database,
|
||||
error: StateError('sqlite could not open'),
|
||||
stackTrace: StackTrace.empty,
|
||||
appVersion: '2.11.0+124',
|
||||
platform: 'windows 11',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(StartupDiagnosticsStore.resetForTesting);
|
||||
|
||||
testWidgets('renders nothing when no launch has failed', (tester) async {
|
||||
await pumpLogs(tester);
|
||||
|
||||
expect(find.byKey(previousStartupFailureKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows the recorded failure so the user can see and act on it', (tester) async {
|
||||
// The failing process left no in-memory log at all; this banner is the
|
||||
// only surface that failure ever reaches (#1732).
|
||||
StartupDiagnosticsStore.setPendingForTesting(record);
|
||||
|
||||
await pumpLogs(tester);
|
||||
|
||||
expect(find.byKey(previousStartupFailureKey), findsOneWidget);
|
||||
expect(find.textContaining('sqlite could not open'), findsOneWidget);
|
||||
expect(find.textContaining('Phase: database'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('enables upload and copy with a record but an empty log buffer', (tester) async {
|
||||
MemoryLogOutput.clearLogs();
|
||||
StartupDiagnosticsStore.setPendingForTesting(record);
|
||||
|
||||
await pumpLogs(tester);
|
||||
|
||||
// Gating these on the log buffer alone would show the record and then
|
||||
// refuse to let the user do anything with it.
|
||||
final bar = tester.widget<FocusableActionBar>(find.byType(FocusableActionBar));
|
||||
for (final tooltip in [t.logs.uploadLogs, t.logs.copyLogs, t.logs.clearLogs]) {
|
||||
final action = bar.actions.singleWhere((candidate) => candidate.tooltip == tooltip);
|
||||
expect(action.onPressed, isNotNull, reason: tooltip);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/prefs_recovery.dart';
|
||||
import 'package:plezy/services/sensitive_prefs.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
|
||||
/// A 32-byte key, the only length `AesGcm.with256bits()` accepts.
|
||||
final String _validVaultKey = base64Encode(List<int>.generate(32, (i) => i));
|
||||
|
||||
String _traktSession({String access = 'trakt-access-token', String refresh = 'trakt-refresh-token'}) => jsonEncode({
|
||||
'access_token': access,
|
||||
'refresh_token': refresh,
|
||||
'expires_at': 4102444800,
|
||||
'username': 'someone',
|
||||
'scope': 'public',
|
||||
'created_at': 1700000000,
|
||||
});
|
||||
|
||||
String _seerrSession({String cookie = 'seerr-connect-sid'}) => jsonEncode({
|
||||
'base_url': 'https://seerr.example.com',
|
||||
'method': 'local',
|
||||
'identifier': 'user@example.com',
|
||||
'secret': 'enc:v1:{"c":"AA==","n":"AA==","m":"AA=="}',
|
||||
'cookie': cookie,
|
||||
'user_id': 1,
|
||||
'permissions': 2,
|
||||
'display_name': 'Someone',
|
||||
'instance_label': 'Seerr',
|
||||
'created_at': 1700000000,
|
||||
});
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
late File store;
|
||||
|
||||
setUp(() async {
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
tempDir = await Directory.systemTemp.createTemp('plezy-prefs-recovery');
|
||||
store = File('${tempDir.path}/shared_preferences.json');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
group('salvage', () {
|
||||
test('recovers a vault key from a truncated document', () {
|
||||
// Exactly the #1732 shape: a non-atomic write cut short, so the object
|
||||
// never closes and `json.decode` cannot help.
|
||||
final raw = '{"theme":"dark","$credentialVaultKeyPref":"$_validVaultKey","library_den';
|
||||
|
||||
expect(PrefsRecovery.salvage(raw).vaultKey, _validVaultKey);
|
||||
});
|
||||
|
||||
test('rejects a vault key that is not 32 bytes', () {
|
||||
final raw = jsonEncode({credentialVaultKeyPref: base64Encode(List<int>.filled(16, 7))});
|
||||
|
||||
final salvaged = PrefsRecovery.salvage(raw);
|
||||
|
||||
// A short key would make every later decrypt throw, which is worse than
|
||||
// reporting the key as lost.
|
||||
expect(salvaged.vaultKey, isNull);
|
||||
expect(salvaged.losses, 1);
|
||||
});
|
||||
|
||||
test('recovers profile-scoped tracker and Seerr sessions', () {
|
||||
final raw = jsonEncode({
|
||||
'user_abc_trakt_session': _traktSession(),
|
||||
'user_abc_seerr_session': _seerrSession(),
|
||||
'theme': 'dark',
|
||||
});
|
||||
|
||||
final salvaged = PrefsRecovery.salvage(raw);
|
||||
|
||||
expect(salvaged.sessions.keys, containsAll(['user_abc_trakt_session', 'user_abc_seerr_session']));
|
||||
expect(salvaged.losses, 0);
|
||||
});
|
||||
|
||||
test('counts an undecodable session as lost rather than reseeding garbage', () {
|
||||
final salvaged = PrefsRecovery.salvage(jsonEncode({'trakt_session': '{"access_token":"only-half'}));
|
||||
|
||||
expect(salvaged.sessions, isEmpty);
|
||||
expect(salvaged.losses, 1);
|
||||
});
|
||||
|
||||
test('normalises a legacy flutter-prefixed key onto the async key', () {
|
||||
// A store damaged mid-migration holds credentials only under the legacy
|
||||
// spelling, because `SharedPreferences.getInstance()` runs first.
|
||||
final raw = jsonEncode({
|
||||
'${legacyKeyPrefix}trakt_session': _traktSession(),
|
||||
'$legacyKeyPrefix$credentialVaultKeyPref': _validVaultKey,
|
||||
});
|
||||
|
||||
final salvaged = PrefsRecovery.salvage(raw);
|
||||
|
||||
expect(salvaged.vaultKey, _validVaultKey);
|
||||
expect(salvaged.sessions.keys, ['trakt_session']);
|
||||
});
|
||||
|
||||
test('prefers the unprefixed entry over the legacy one regardless of order', () {
|
||||
final documents = [
|
||||
jsonEncode({
|
||||
'${legacyKeyPrefix}trakt_session': _traktSession(access: 'legacy-access'),
|
||||
'trakt_session': _traktSession(access: 'current-access'),
|
||||
}),
|
||||
jsonEncode({
|
||||
'trakt_session': _traktSession(access: 'current-access'),
|
||||
'${legacyKeyPrefix}trakt_session': _traktSession(access: 'legacy-access'),
|
||||
}),
|
||||
];
|
||||
|
||||
for (final raw in documents) {
|
||||
expect(PrefsRecovery.salvage(raw).sessions['trakt_session'], contains('current-access'));
|
||||
}
|
||||
});
|
||||
|
||||
test('registers each secret individually, not just the whole payload', () {
|
||||
final raw = jsonEncode({
|
||||
'trakt_session': _traktSession(access: 'aaa-access-secret', refresh: 'bbb-refresh-secret'),
|
||||
'seerr_session': _seerrSession(cookie: 'ccc-cookie-secret'),
|
||||
legacyPlexTokenPref: 'ddd-plex-secret',
|
||||
});
|
||||
|
||||
PrefsRecovery.salvage(raw);
|
||||
|
||||
// A bare token quoted on its own must redact too; registering only the
|
||||
// encoded blob would leave these exposed.
|
||||
for (final secret in ['aaa-access-secret', 'bbb-refresh-secret', 'ccc-cookie-secret', 'ddd-plex-secret']) {
|
||||
expect(LogRedactionManager.redact('value=$secret'), isNot(contains(secret)), reason: secret);
|
||||
}
|
||||
});
|
||||
|
||||
test('registers the salvaged vault key for redaction', () {
|
||||
PrefsRecovery.salvage(jsonEncode({credentialVaultKeyPref: _validVaultKey}));
|
||||
|
||||
expect(LogRedactionManager.redact('key=$_validVaultKey'), isNot(contains(_validVaultKey)));
|
||||
});
|
||||
});
|
||||
|
||||
group('assertStoreReadable', () {
|
||||
test('accepts a well-formed document', () async {
|
||||
await store.writeAsString(
|
||||
jsonEncode({
|
||||
'theme': 'dark',
|
||||
'count': 3,
|
||||
'list': <String>['a'],
|
||||
}),
|
||||
);
|
||||
|
||||
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
|
||||
});
|
||||
|
||||
test('accepts a missing or empty store', () async {
|
||||
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
|
||||
|
||||
await store.writeAsString('');
|
||||
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
|
||||
});
|
||||
|
||||
test('rejects a truncated document', () async {
|
||||
await store.writeAsString('{"theme":"dark"');
|
||||
|
||||
await expectLater(
|
||||
PrefsRecovery.assertStoreReadable(storeFileOverride: store),
|
||||
throwsA(isA<CorruptPreferenceStoreException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a null value before the plugin can memoise it', () async {
|
||||
// Valid JSON, so `json.decode` succeeds and the plugin caches the lazy
|
||||
// cast; the TypeError only lands later in `Map<String, Object>.from`.
|
||||
// Detecting it after that point would leave the bad document memoised
|
||||
// and a repair unable to reopen onto a clean store.
|
||||
await store.writeAsString('{"theme":"dark","broken":null}');
|
||||
|
||||
await expectLater(
|
||||
PrefsRecovery.assertStoreReadable(storeFileOverride: store),
|
||||
throwsA(isA<CorruptPreferenceStoreException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a document that is not a JSON object', () async {
|
||||
await store.writeAsString('[1,2,3]');
|
||||
|
||||
await expectLater(
|
||||
PrefsRecovery.assertStoreReadable(storeFileOverride: store),
|
||||
throwsA(isA<CorruptPreferenceStoreException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('quarantine', () {
|
||||
test('moves the damaged file aside and keeps its bytes', () async {
|
||||
final raw = '{"$credentialVaultKeyPref":"$_validVaultKey","trunc';
|
||||
await store.writeAsString(raw);
|
||||
|
||||
final result = await PrefsRecovery.quarantine(storeFileOverride: store);
|
||||
|
||||
expect(await store.exists(), isFalse);
|
||||
expect(result.backupPath, isNotNull);
|
||||
expect(await File(result.backupPath!).readAsString(), raw);
|
||||
expect(result.salvaged.vaultKey, _validVaultKey);
|
||||
});
|
||||
|
||||
test('deleteBackup removes the credential-bearing copy', () async {
|
||||
await store.writeAsString('{"broken');
|
||||
final result = await PrefsRecovery.quarantine(storeFileOverride: store);
|
||||
|
||||
await PrefsRecovery.deleteBackup(result.backupPath!);
|
||||
|
||||
expect(await File(result.backupPath!).exists(), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('backupStore', () {
|
||||
test('copies without disturbing the live store', () async {
|
||||
await store.writeAsString('{"theme":"dark"}');
|
||||
|
||||
final path = await PrefsRecovery.backupStore(storeFileOverride: store);
|
||||
|
||||
expect(await store.exists(), isTrue);
|
||||
expect(await File(path!).readAsString(), '{"theme":"dark"}');
|
||||
});
|
||||
});
|
||||
|
||||
group('seedStore', () {
|
||||
test('writes only the salvaged credentials and leaves no staging file', () async {
|
||||
final salvaged = PrefsRecovery.salvage(
|
||||
jsonEncode({credentialVaultKeyPref: _validVaultKey, 'trakt_session': _traktSession(), 'theme': 'dark'}),
|
||||
);
|
||||
|
||||
expect(await PrefsRecovery.seedStore(salvaged, storeFileOverride: store), isTrue);
|
||||
|
||||
final written = jsonDecode(await store.readAsString()) as Map<String, dynamic>;
|
||||
expect(written[credentialVaultKeyPref], _validVaultKey);
|
||||
expect(written.containsKey('trakt_session'), isTrue);
|
||||
// Ordinary settings are not salvaged, so a reseed must not invent them.
|
||||
expect(written.containsKey('theme'), isFalse);
|
||||
expect(await File('${store.path}.seed').exists(), isFalse);
|
||||
});
|
||||
|
||||
test('the seeded store passes the preflight it will face on restart', () async {
|
||||
final salvaged = PrefsRecovery.salvage(jsonEncode({credentialVaultKeyPref: _validVaultKey}));
|
||||
await PrefsRecovery.seedStore(salvaged, storeFileOverride: store);
|
||||
|
||||
await expectLater(PrefsRecovery.assertStoreReadable(storeFileOverride: store), completes);
|
||||
});
|
||||
|
||||
test('reports false when there is nothing to seed', () async {
|
||||
expect(await PrefsRecovery.seedStore(SalvagedPrefsCredentials.empty, storeFileOverride: store), isFalse);
|
||||
expect(await store.exists(), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('CorruptPreferenceStoreException', () {
|
||||
test('never renders the document the parser choked on', () {
|
||||
// `FormatException.toString()` prints an excerpt of `source` around
|
||||
// `offset`. During startup that source is the credential store.
|
||||
final source = '{"$credentialVaultKeyPref":"$_validVaultKey","truncated';
|
||||
late final FormatException raw;
|
||||
try {
|
||||
jsonDecode(source);
|
||||
fail('expected a FormatException');
|
||||
} on FormatException catch (error) {
|
||||
raw = error;
|
||||
}
|
||||
expect(raw.toString(), contains(_validVaultKey), reason: 'precondition: the raw error does leak the key');
|
||||
|
||||
final wrapped = CorruptPreferenceStoreException(raw, StackTrace.current);
|
||||
|
||||
expect(wrapped.toString(), isNot(contains(_validVaultKey)));
|
||||
expect(wrapped.toString(), contains('FormatException'));
|
||||
expect(wrapped.causeType, 'FormatException');
|
||||
});
|
||||
});
|
||||
|
||||
group('UnreadableSensitivePreferenceException', () {
|
||||
test('names the key but carries no value', () {
|
||||
final exception = UnreadableSensitivePreferenceException(credentialVaultKeyPref, TypeError());
|
||||
|
||||
expect(exception.key, credentialVaultKeyPref);
|
||||
expect(exception.toString(), contains(credentialVaultKeyPref));
|
||||
});
|
||||
});
|
||||
|
||||
group('sensitive key registry', () {
|
||||
test('covers every credential slot, scoped and unscoped', () {
|
||||
for (final key in [
|
||||
credentialVaultKeyPref,
|
||||
legacyPlexTokenPref,
|
||||
'trakt_session',
|
||||
'user_abc_mal_session',
|
||||
'user_abc_anilist_session',
|
||||
'user_abc_simkl_session',
|
||||
'seerr_session',
|
||||
'user_abc_seerr_session',
|
||||
]) {
|
||||
expect(isSensitivePrefKey(key), isTrue, reason: key);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not claim ordinary preferences', () {
|
||||
for (final key in ['theme', 'library_density', 'custom_relay_url', 'session_count']) {
|
||||
expect(isSensitivePrefKey(key), isFalse, reason: key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/sensitive_prefs.dart';
|
||||
import 'package:plezy/services/startup_diagnostics.dart';
|
||||
import 'package:plezy/utils/log_redaction_manager.dart';
|
||||
|
||||
StartupFailureRecord _record({
|
||||
StartupPhase? phase = StartupPhase.database,
|
||||
Object error = const FormatException('boom'),
|
||||
StackTrace? stackTrace,
|
||||
}) => StartupFailureRecord.fromError(
|
||||
phase: phase,
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
appVersion: '2.11.0+124',
|
||||
platform: 'windows 11',
|
||||
);
|
||||
|
||||
void main() {
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
StartupDiagnosticsStore.resetForTesting();
|
||||
tempDir = await Directory.systemTemp.createTemp('plezy-startup-diagnostics');
|
||||
StartupDiagnosticsStore.debugDirectoryOverride = tempDir;
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
StartupDiagnosticsStore.resetForTesting();
|
||||
LogRedactionManager.clearTrackedValues();
|
||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
group('StartupPhaseException', () {
|
||||
test('unwraps to the real cause and reports the phase', () {
|
||||
const inner = FormatException('inner');
|
||||
const wrapped = StartupPhaseException(StartupPhase.storage, inner);
|
||||
|
||||
expect(StartupPhaseException.unwrap(wrapped), same(inner));
|
||||
expect(StartupPhaseException.phaseOf(wrapped), StartupPhase.storage);
|
||||
expect(StartupPhaseException.unwrap(inner), same(inner));
|
||||
});
|
||||
|
||||
test('a record built from a wrapper describes the cause, not the wrapper', () {
|
||||
final record = StartupFailureRecord.fromError(
|
||||
error: const StartupPhaseException(StartupPhase.database, FormatException('inner')),
|
||||
stackTrace: StackTrace.empty,
|
||||
appVersion: 'v',
|
||||
platform: 'p',
|
||||
);
|
||||
|
||||
expect(record.phase, StartupPhase.database);
|
||||
expect(record.errorType, 'FormatException');
|
||||
});
|
||||
});
|
||||
|
||||
group('describeErrorSafely', () {
|
||||
test('drops the source excerpt a FormatException carries', () {
|
||||
final key = base64Encode(List<int>.generate(32, (i) => i));
|
||||
final source = '{"$credentialVaultKeyPref":"$key","truncated';
|
||||
late final FormatException raw;
|
||||
try {
|
||||
jsonDecode(source);
|
||||
fail('expected a FormatException');
|
||||
} on FormatException catch (error) {
|
||||
raw = error;
|
||||
}
|
||||
expect(raw.toString(), contains(key), reason: 'precondition: the raw error does leak the key');
|
||||
|
||||
final described = StartupFailureRecord.describeErrorSafely(raw);
|
||||
|
||||
expect(described, isNot(contains(key)));
|
||||
expect(described, contains('offset'));
|
||||
});
|
||||
|
||||
test('a record never persists the leaked excerpt', () async {
|
||||
final key = base64Encode(List<int>.generate(32, (i) => i));
|
||||
late final FormatException raw;
|
||||
try {
|
||||
jsonDecode('{"$credentialVaultKeyPref":"$key","truncated');
|
||||
fail('expected a FormatException');
|
||||
} on FormatException catch (error) {
|
||||
raw = error;
|
||||
}
|
||||
|
||||
final record = _record(error: raw);
|
||||
await StartupDiagnosticsStore.record(record);
|
||||
|
||||
final onDisk = await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').readAsString();
|
||||
expect(record.describe(), isNot(contains(key)));
|
||||
expect(onDisk, isNot(contains(key)));
|
||||
});
|
||||
|
||||
test('leaves other error types alone', () {
|
||||
expect(StartupFailureRecord.describeErrorSafely(StateError('plain')), contains('plain'));
|
||||
});
|
||||
});
|
||||
|
||||
group('record contents', () {
|
||||
test('redacts registered secrets out of the message', () {
|
||||
LogRedactionManager.registerToken('super-secret-token');
|
||||
|
||||
final record = _record(error: StateError('failed with super-secret-token'));
|
||||
|
||||
expect(record.message, isNot(contains('super-secret-token')));
|
||||
});
|
||||
|
||||
test('describe() carries the phase, type and build for a bug report', () {
|
||||
final text = _record(error: StateError('nope')).describe();
|
||||
|
||||
expect(text, contains('Phase: database'));
|
||||
expect(text, contains('Error: StateError'));
|
||||
expect(text, contains('2.11.0+124'));
|
||||
expect(text, contains('windows 11'));
|
||||
});
|
||||
|
||||
test('headline stays one line', () {
|
||||
expect(_record().headline, startsWith('[database] FormatException'));
|
||||
});
|
||||
});
|
||||
|
||||
group('persistence', () {
|
||||
test('round-trips a record through disk', () async {
|
||||
final original = _record(error: StateError('disk failure'), stackTrace: StackTrace.fromString('#0 frame'));
|
||||
await StartupDiagnosticsStore.record(original);
|
||||
StartupDiagnosticsStore.resetForTesting();
|
||||
StartupDiagnosticsStore.debugDirectoryOverride = tempDir;
|
||||
|
||||
final restored = await StartupDiagnosticsStore.consumePrevious();
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.phase, StartupPhase.database);
|
||||
expect(restored.errorType, 'StateError');
|
||||
expect(restored.message, contains('disk failure'));
|
||||
expect(restored.stackTrace, contains('#0 frame'));
|
||||
});
|
||||
|
||||
test('consuming deletes the file but keeps the record available in-session', () async {
|
||||
await StartupDiagnosticsStore.record(_record());
|
||||
StartupDiagnosticsStore.resetForTesting();
|
||||
StartupDiagnosticsStore.debugDirectoryOverride = tempDir;
|
||||
|
||||
await StartupDiagnosticsStore.consumePrevious();
|
||||
|
||||
// Deleted so one stale failure cannot follow the user forever, but held
|
||||
// in memory so Settings > Logs can still show and upload it.
|
||||
expect(await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').exists(), isFalse);
|
||||
expect(StartupDiagnosticsStore.pending, isNotNull);
|
||||
});
|
||||
|
||||
test('consuming nothing yields null', () async {
|
||||
expect(await StartupDiagnosticsStore.consumePrevious(), isNull);
|
||||
expect(StartupDiagnosticsStore.pending, isNull);
|
||||
});
|
||||
|
||||
test('a malformed record is ignored rather than thrown', () async {
|
||||
await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').writeAsString('not json');
|
||||
|
||||
expect(await StartupDiagnosticsStore.consumePrevious(), isNull);
|
||||
});
|
||||
|
||||
test('clear removes both the file and the pending record', () async {
|
||||
await StartupDiagnosticsStore.record(_record());
|
||||
|
||||
await StartupDiagnosticsStore.clear();
|
||||
|
||||
expect(await File('${tempDir.path}/${StartupDiagnosticsStore.fileName}').exists(), isFalse);
|
||||
expect(StartupDiagnosticsStore.pending, isNull);
|
||||
});
|
||||
|
||||
test('an unwritable location degrades instead of failing the failure path', () async {
|
||||
StartupDiagnosticsStore.debugDirectoryOverride = Directory('${tempDir.path}/missing/deeper');
|
||||
|
||||
await expectLater(StartupDiagnosticsStore.record(_record()), completes);
|
||||
// Still exposed in-session even when it could not be written.
|
||||
expect(StartupDiagnosticsStore.pending, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('phase ids', () {
|
||||
test('round-trip through their stable wire form', () {
|
||||
for (final phase in StartupPhase.values) {
|
||||
expect(StartupPhase.fromId(phase.id), phase, reason: phase.name);
|
||||
}
|
||||
expect(StartupPhase.fromId('nonexistent'), isNull);
|
||||
expect(StartupPhase.fromId(null), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,9 @@ import 'package:plezy/main.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/models/download_models.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/prefs_recovery.dart';
|
||||
import 'package:plezy/services/startup_diagnostics.dart';
|
||||
import 'package:plezy/widgets/startup_failure_view.dart';
|
||||
|
||||
import 'test_helpers/download_fixtures.dart';
|
||||
import 'test_helpers/prefs.dart';
|
||||
@@ -89,18 +92,135 @@ void main() {
|
||||
expect(find.byKey(startupBootstrapProgressKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows a localized recoverable failure instead of removing Flutter UI', (tester) async {
|
||||
testWidgets('names the failing phase instead of showing a bare error', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async => throw StateError('database unavailable'),
|
||||
initialize: () async => throw const StartupPhaseException(StartupPhase.database, FormatException('boom')),
|
||||
buildApp: (_, value) => Text('ready $value'),
|
||||
reportFailure: (_, _, _) async {},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
||||
expect(find.text('Error'), findsOneWidget);
|
||||
expect(find.text('Retry'), findsOneWidget);
|
||||
expect(find.byKey(startupBootstrapRetryKey), findsOneWidget);
|
||||
expect(find.byKey(startupFailureCopyKey), findsOneWidget);
|
||||
// The phase and concrete type are what turn "Error" into a report.
|
||||
expect(find.textContaining('database'), findsOneWidget);
|
||||
expect(find.textContaining('FormatException'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('expands the full detail block on request', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async => throw StateError('database unavailable'),
|
||||
buildApp: (_, value) => Text('ready $value'),
|
||||
reportFailure: (_, _, _) async {},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(startupFailureDetailsKey), findsNothing);
|
||||
await tester.tap(find.text('Show details'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(startupFailureDetailsKey), findsOneWidget);
|
||||
expect(find.textContaining('database unavailable'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('offers a repair only for a repairable failure', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async => throw StateError('unrelated'),
|
||||
buildApp: (_, value) => Text('ready $value'),
|
||||
reportFailure: (_, _, _) async {},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(startupFailureRepairKey), findsNothing);
|
||||
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
key: const Key('repairable'),
|
||||
initialize: () async => throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current),
|
||||
buildApp: (_, value) => Text('ready $value'),
|
||||
reportFailure: (_, _, _) async {},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(startupFailureRepairKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('re-runs initialization after a successful repair', (tester) async {
|
||||
var attempts = 0;
|
||||
var repairCalls = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async {
|
||||
attempts++;
|
||||
if (attempts == 1) {
|
||||
throw CorruptPreferenceStoreException(const FormatException('bad'), StackTrace.current);
|
||||
}
|
||||
return 7;
|
||||
},
|
||||
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||
reportFailure: (_, _, _) async {},
|
||||
repair: (_, _, _) async {
|
||||
repairCalls++;
|
||||
return true;
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(startupFailureRepairKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(repairCalls, 1);
|
||||
expect(attempts, 2);
|
||||
expect(find.text('ready 7'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a repair that reports no change does not retry', (tester) async {
|
||||
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')),
|
||||
reportFailure: (_, _, _) async {},
|
||||
repair: (_, _, _) async => false,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(startupFailureRepairKey));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(attempts, 1);
|
||||
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('reports the failure to the crash reporter', (tester) async {
|
||||
StartupFailureRecord? reported;
|
||||
|
||||
await tester.pumpWidget(
|
||||
StartupBootstrap<int>(
|
||||
initialize: () async => throw const StartupPhaseException(StartupPhase.storage, 'nope'),
|
||||
buildApp: (_, value) => Text('ready $value'),
|
||||
reportFailure: (record, _, _) async => reported = record,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// The gate catches the error, so nothing else would ever see it.
|
||||
expect(reported?.phase, StartupPhase.storage);
|
||||
});
|
||||
|
||||
testWidgets('retry clears the failed generation and can commit a later success', (tester) async {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/main.dart';
|
||||
import 'package:plezy/services/prefs_recovery.dart';
|
||||
|
||||
Future<void> _openDialog(
|
||||
WidgetTester tester,
|
||||
PrefsRepairOutcome outcome, {
|
||||
Future<void> Function(String path)? deleteBackup,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MaterialApp(
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () =>
|
||||
showRepairOutcomeDialog(context, outcome, deleteBackup: deleteBackup ?? PrefsRecovery.deleteBackup),
|
||||
child: const Text('open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||
|
||||
late Directory tempDir;
|
||||
late File backup;
|
||||
|
||||
setUp(() async {
|
||||
tempDir = await Directory.systemTemp.createTemp('plezy-repair-outcome');
|
||||
backup = File('${tempDir.path}/shared_preferences.corrupt-x.json');
|
||||
await backup.writeAsString('{"credential_vault_key_v1":"secret"}');
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
testWidgets('warns that the backup holds credentials and must not be shared', (tester) async {
|
||||
await _openDialog(
|
||||
tester,
|
||||
PrefsRepairOutcome(backupPath: backup.path, vaultKeySalvaged: true, sessionsSalvaged: 0, sessionsLost: 0),
|
||||
);
|
||||
|
||||
expect(find.text(t.startup.backupTitle), findsOneWidget);
|
||||
expect(find.text(t.startup.backupWarning), findsOneWidget);
|
||||
expect(find.text(backup.path), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('deleting the backup removes the file and stops showing its path', (tester) async {
|
||||
final deleted = <String>[];
|
||||
await _openDialog(
|
||||
tester,
|
||||
PrefsRepairOutcome(backupPath: backup.path, vaultKeySalvaged: true, sessionsSalvaged: 0, sessionsLost: 0),
|
||||
// The widget-test binding's fake-async zone never completes a `dart:io`
|
||||
// future, so the real delete is covered in prefs_recovery_test.dart and
|
||||
// this test owns the UI state that follows it.
|
||||
deleteBackup: (path) async => deleted.add(path),
|
||||
);
|
||||
|
||||
await tester.tap(find.text(t.startup.deleteBackup));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(deleted, [backup.path]);
|
||||
// Regression: the flag lived inside the StatefulBuilder closure, so the
|
||||
// rebuild it triggered reset it and the sensitive path stayed on screen.
|
||||
expect(find.text(t.startup.backupDeleted), findsOneWidget);
|
||||
expect(find.text(backup.path), findsNothing);
|
||||
expect(find.text(t.startup.deleteBackup), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('says sign-ins are kept only when the vault key survived', (tester) async {
|
||||
await _openDialog(
|
||||
tester,
|
||||
PrefsRepairOutcome(backupPath: null, vaultKeySalvaged: true, sessionsSalvaged: 2, sessionsLost: 0),
|
||||
);
|
||||
|
||||
expect(find.text(t.startup.repairKeptSignIns), findsOneWidget);
|
||||
expect(find.text(t.startup.repairLostSignIns), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('states the full credential loss when the vault key is gone', (tester) async {
|
||||
await _openDialog(
|
||||
tester,
|
||||
PrefsRepairOutcome(backupPath: null, vaultKeySalvaged: false, sessionsSalvaged: 0, sessionsLost: 3),
|
||||
);
|
||||
|
||||
expect(find.text(t.startup.repairLostSignIns), findsOneWidget);
|
||||
// Tracker/Seerr sessions are plaintext preference entries, so they are
|
||||
// reported separately from the vault-protected server tokens.
|
||||
expect(find.text(t.startup.repairLostSessions), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('asks for a restart when the store could not be reopened', (tester) async {
|
||||
await _openDialog(
|
||||
tester,
|
||||
PrefsRepairOutcome(
|
||||
backupPath: null,
|
||||
vaultKeySalvaged: true,
|
||||
sessionsSalvaged: 1,
|
||||
sessionsLost: 0,
|
||||
requiresRestart: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text(t.startup.repairNeedsRestart), findsOneWidget);
|
||||
expect(find.text(t.startup.repairSucceeded), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -111,6 +111,16 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
# Application build; see runner/CMakeLists.txt.
|
||||
add_subdirectory("runner")
|
||||
|
||||
# Keep MSVC link by-products out of the runnable bundle directory. CI uploads
|
||||
# the executable output directory directly, so import and static libraries
|
||||
# emitted there would otherwise be packaged with the application.
|
||||
set_target_properties(
|
||||
${BINARY_NAME}
|
||||
simdutf
|
||||
PROPERTIES
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/link-artifacts/$<CONFIG>"
|
||||
)
|
||||
|
||||
|
||||
# Sentry native crash capture: use the in-process handler instead of
|
||||
# crashpad. Crashpad uploads minidumps straight to the DSN's /minidump
|
||||
|
||||
Reference in New Issue
Block a user