diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 440f60d5..663d1495 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index fbff4e13..71adefe5 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -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 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 _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 _removeOrphanedDatabaseSidecars(File databaseFile) async { for (final suffix in const ['-wal', '-shm']) { final sidecar = File('${databaseFile.path}$suffix'); - if (await sidecar.exists()) await sidecar.delete(); + 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); + } } } diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 02232c33..d55c7fc2 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -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", diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index e4f3cce2..93a4d5d0 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -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": "Лиценз", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 44e235c4..87756b39 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -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", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index a9acbb29..6f0f9fd9 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -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", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index c097b576..16825b42 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 6dfe7a79..c2f7c446 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -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", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index d3df75d0..739d5f47 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -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", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 942146e9..21573411 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -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", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 61d24247..38783cf1 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -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", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index f40f7bdb..5ec078b2 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -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": "ライセンス", diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 35dac301..c2a4f914 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -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": "Лицензия", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 802d7911..1d32992a 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -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": "라이선스", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index d80c19ad..0888e5f5 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -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", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 12844999..7c52e2c9 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -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", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 9a762b0d..f68613cf 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -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", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 406f5ffd..cd9c1659 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -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", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index e851058c..86a86a86 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -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": "Лицензия", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index ac1fbf02..e17c8a29 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 32913 (1496 per locale) +/// Strings: 32938 (1497 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 03bf62c2..ca08906c 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -68,6 +68,7 @@ class Translations with BaseTranslations { 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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 1cd012bc..48831aa0 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -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", diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index ee9c97be..6164c485 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -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", diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index e67d0b5e..533023a9 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -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", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 16496fa0..93d9e8ca 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -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": "授權", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 9d62ef6c..bd1d0645 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -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": "许可证", diff --git a/lib/main.dart b/lib/main.dart index 0bcf31b5..711a3dec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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,44 +168,265 @@ void _bootstrapApp() { ); } +/// 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 _gatePhase(StartupPhase phase, Future 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 _optionalGatePhase( + StartupPhase phase, + Future 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.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 SettingsService.getInstance(); + final settings = await _gatePhase(StartupPhase.preferences, SettingsService.getInstance); setLoggerLevel(settings.read(SettingsService.enableDebugLogging)); - _StartupDependencies? dependencies; - Future initializeStartup() async { - AndroidExitDiagnostics.markTelemetryReady(); - dependencies = await _initializeStartup(settings); + if (_enableSentry) { + // 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 = _sentryRelease(); + if (_sentryEnvironment.isNotEmpty) options.environment = _sentryEnvironment; + if (_sentryDist.isNotEmpty) options.dist = _sentryDist; + options.tracesSampleRate = 0; + options.attachStacktrace = true; + options.enableAutoSessionTracking = false; + options.recordHttpBreadcrumbs = false; + options.captureNativeFailedRequests = false; + options.enableAppHangTracking = !kDebugMode; + options.appHangTimeoutInterval = const Duration(seconds: 3); + options.beforeSend = _beforeSend; + options.beforeBreadcrumb = _beforeBreadcrumb; + }); + }); } - if (_enableSentry) { - final packageInfo = await PackageInfo.fromPlatform(); - await SentryFlutter.init((options) { - options.dsn = _sentryDsn; - options.release = gitCommit.isNotEmpty - ? 'plezy@${gitCommit.substring(0, 7)}' - : 'plezy@${packageInfo.version}+${packageInfo.buildNumber}'; - if (_sentryEnvironment.isNotEmpty) options.environment = _sentryEnvironment; - if (_sentryDist.isNotEmpty) options.dist = _sentryDist; - options.tracesSampleRate = 0; - options.attachStacktrace = true; - options.enableAutoSessionTracking = false; - options.recordHttpBreadcrumbs = false; - options.captureNativeFailedRequests = false; - options.enableAppHangTracking = !kDebugMode; - 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 _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 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 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 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 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( + 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 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 extends StatefulWidget { final Widget Function(BuildContext context, T value) buildApp; final FutureOr Function(T value)? discard; final FutureOr 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 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 Function(BuildContext context, StartupFailureRecord record, Object error)? repair; + final ThemeData? lightTheme; final ThemeData? darkTheme; final material.ThemeMode themeMode; @@ -229,9 +474,14 @@ class StartupBootstrap extends StatefulWidget { class _StartupBootstrapState extends State> { 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 extends State> { final generation = ++_generation; setState(() { - _error = null; + _failure = null; _initializing = true; }); @@ -264,17 +514,52 @@ class _StartupBootstrapState extends State> { _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 _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 _discard(T value) async { try { await widget.discard?.call(value); @@ -309,30 +594,16 @@ class _StartupBootstrapState extends State> { } 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 { - final savedLocale = settings.read(SettingsService.appLocale); - await LocaleSettings.setLocale(savedLocale); - await initializeDateFormatting(savedLocale.intlLocaleName, null); + // 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 = >[]; + // 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( - openDatabase: () => AppDatabase.open(isTvos: PlatformDetector.isAppleTV()), - recoverNativeDownloads: DownloadManagerService.discardInterruptedNativeDownloadsAfterStorageFailure, - storageFullMessage: t.downloads.storageFull, + 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( diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 4290cc21..0a849a55 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -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 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 _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 with MountedSetStateMixin { } String _formatAllLogs({int? maxBytes}) { - final header = _deviceInfo.isEmpty ? '' : '$_deviceInfo\n---\n'; + final sections = [ + 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 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)['id'] as String; - unawaited( showScopedDialog( context: context, @@ -330,6 +314,50 @@ class _LogsScreenState extends State 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 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 diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart index 82219a76..2a438d9c 100644 --- a/lib/services/base_shared_preferences_service.dart +++ b/lib/services/base_shared_preferences_service.dart @@ -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 _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 _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 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 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 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(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 readStringList(String key, {List 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 { 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 writeTo(BaseSharedPreferencesService svc, String? value) async { final normalized = transform == null ? value : transform!(value); @@ -313,7 +473,7 @@ class EnumPref extends Pref { 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 extends Pref { @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)); diff --git a/lib/services/log_upload_service.dart b/lib/services/log_upload_service.dart new file mode 100644 index 00000000..8544ad25 --- /dev/null +++ b/lib/services/log_upload_service.dart @@ -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 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)['id'] as String; +} diff --git a/lib/services/prefs_recovery.dart b/lib/services/prefs_recovery.dart new file mode 100644 index 00000000..10b3b66a --- /dev/null +++ b/lib/services/prefs_recovery.dart @@ -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 `/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 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.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 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` + /// — so a document containing a null value caches successfully and only + /// throws later, when `Map.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 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 seedStore(SalvagedPrefsCredentials salvaged, {File? storeFileOverride}) async { + final vaultKey = salvaged.vaultKey; + if (vaultKey == null && salvaged.sessions.isEmpty) return false; + final values = {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 = {}; + final sessionFromLegacy = {}; + 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 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 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; + } + } +} diff --git a/lib/services/sensitive_prefs.dart b/lib/services/sensitive_prefs.dart new file mode 100644 index 00000000..4fe92c5b --- /dev/null +++ b/lib/services/sensitive_prefs.dart @@ -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 trackerSessionBaseKeys = [ + '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 profileScopedCredentialBaseKeys = [...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; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index b572ae24..1fd1b11d 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -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) { diff --git a/lib/services/startup_diagnostics.dart b/lib/services/startup_diagnostics.dart new file mode 100644 index 00000000..4e56ddf8 --- /dev/null +++ b/lib/services/startup_diagnostics.dart @@ -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 toJson() => { + 'phase': phase?.id, + 'errorType': errorType, + 'message': message, + 'stackTrace': stackTrace, + 'timestamp': timestamp.toUtc().toIso8601String(), + 'appVersion': appVersion, + 'platform': platform, + 'repairable': repairable, + }; + + static StartupFailureRecord? fromJson(Map 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() 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 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 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()); + 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 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; +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 34f4cccf..00537545 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -141,7 +141,7 @@ class StorageService extends BaseSharedPreferencesService { String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration( 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( _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 diff --git a/lib/widgets/startup_failure_view.dart b/lib/widgets/startup_failure_view.dart new file mode 100644 index 00000000..253433ca --- /dev/null +++ b/lib/widgets/startup_failure_view.dart @@ -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 Function()? onRepair; + + final bool busy; + + @override + State createState() => _StartupFailureViewState(); +} + +class _StartupFailureViewState extends State { + 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 _uploadDetails() async { + setState(() => _uploading = true); + try { + final id = await uploadDiagnosticText(widget.failure.describe()); + if (!mounted) return; + await showScopedDialog( + 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), + ), + ), + ), + ], + ); + } +} diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index cf6977f4..a310197d 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -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('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_'); diff --git a/test/screens/settings/logs_screen_test.dart b/test/screens/settings/logs_screen_test.dart index 4283d9c4..c11ad0fd 100644 --- a/test/screens/settings/logs_screen_test.dart +++ b/test/screens/settings/logs_screen_test.dart @@ -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 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(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); + } + }); + }); } diff --git a/test/services/prefs_recovery_test.dart b/test/services/prefs_recovery_test.dart new file mode 100644 index 00000000..71433eb7 --- /dev/null +++ b/test/services/prefs_recovery_test.dart @@ -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.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.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': ['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()), + ); + }); + + 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.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()), + ); + }); + + 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()), + ); + }); + }); + + 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; + 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); + } + }); + }); +} diff --git a/test/services/startup_diagnostics_test.dart b/test/services/startup_diagnostics_test.dart new file mode 100644 index 00000000..434260cc --- /dev/null +++ b/test/services/startup_diagnostics_test.dart @@ -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.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.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); + }); + }); +} diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index a4ef9951..b29f0393 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -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( - 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( + 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( + initialize: () async => throw StateError('unrelated'), + buildApp: (_, value) => Text('ready $value'), + reportFailure: (_, _, _) async {}, + ), + ); + await tester.pump(); + expect(find.byKey(startupFailureRepairKey), findsNothing); + + await tester.pumpWidget( + StartupBootstrap( + 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( + 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( + 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( + 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 { diff --git a/test/widgets/startup_repair_outcome_test.dart b/test/widgets/startup_repair_outcome_test.dart new file mode 100644 index 00000000..e106e8bd --- /dev/null +++ b/test/widgets/startup_repair_outcome_test.dart @@ -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 _openDialog( + WidgetTester tester, + PrefsRepairOutcome outcome, { + Future 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 = []; + 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); + }); +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index f16e418a..bea16bd0 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -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/$" +) + # Sentry native crash capture: use the in-process handler instead of # crashpad. Crashpad uploads minidumps straight to the DSN's /minidump