fix(startup): report and repair a failed launch instead of showing "Error"

Since 2.10.0 the whole app sits behind one all-or-nothing initialization
gate, and that gate discarded the only evidence of its own failure. It
caught the error, logged nothing but `error.runtimeType`, rendered an
icon plus the word "Error" plus Retry, and never reported the error
because catching it kept the crash reporter from ever seeing it. There
is no log file on any platform, the buffer is in memory only, a
double-clicked Windows release build has no console, and the log viewer
lives in Settings, behind the gate that just failed. #1732 is the result:
a Windows 11 user whose app will not boot and who cannot produce a single
byte of diagnostic detail.

The gate now names its phases. Each step is wrapped so a throw carries
the phase it came from, replacing a `Future.wait` that discarded every
error but the first and could not attribute it to any of four concurrent
steps. The failure screen renders the phase, the exception type, the
message and an expandable stack, plus copy and upload actions that reuse
the existing log-relay flow. The record is persisted next to the database
so the next successful launch can surface it in Settings > Logs, and it
is reported to the crash reporter explicitly.

Only preferences and the database still gate the launch. Window chrome,
locale, crash-reporting init, TV/performance detection, the image-cache
budget and download storage are best-effort and time-bounded, so a
stalled platform thread degrades instead of holding the splash forever.
Sentry no longer receives the startup work as its `appRunner`: that made
a startup failure indistinguishable from a Sentry failure, and the guard
would then have re-run migrations and the database open a second time.

The two remaining fatal steps become recoverable. Preference reads
tolerate a value whose stored type no longer matches, dropping the key
and defaulting instead of failing the boot. A store that cannot be parsed
is detected before either desktop plugin backend can memoise it, which is
what makes an in-process repair possible at all. Repair is never
automatic: it states what it will cost, salvages the credential-vault key
and every tracker and Seerr session it can validate out of the damaged
bytes, reseeds them, and moves the original aside rather than deleting
it. Servers and profiles survive a salvaged key because their tokens are
ciphertext in the database; tracker and Seerr sessions are plaintext
preference entries, so the copy says they may still need reconnecting.

Nothing derived from the store reaches a diagnostic. `FormatException`
prints an excerpt of whatever it failed to parse, and during startup that
document holds the vault key, refresh tokens and session cookies while
the redaction manager still has nothing registered, so the wrapper keeps
only the cause's type and offset and the record is an allowlist of
already-redacted fields. The quarantined copy is labelled as containing
credentials, is never offered for upload, and can be deleted from the
dialog.

Also self-heals orphaned WAL/SHM sidecars on desktop rather than only
tvOS, makes every `createTable` migration step idempotent, keeps MSVC
link by-products out of the Windows bundle, and asserts bundle contents
in CI.

Refs #1732
This commit is contained in:
edde746
2026-07-31 21:45:32 +02:00
parent 7c515bf8fa
commit 7f0cad339c
43 changed files with 3453 additions and 165 deletions
@@ -1,8 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
import '../utils/app_logger.dart';
import 'prefs_recovery.dart';
import 'sensitive_prefs.dart';
/// Base class for services that use SharedPreferences singleton pattern.
///
/// This class handles the boilerplate for singleton initialization and
@@ -81,6 +86,23 @@ abstract class BaseSharedPreferencesService {
}
static Future<SharedPreferencesWithCache> _loadSharedCache() async {
// Validate before the plugin reads anything: the desktop backends memoise
// the document they parse and never re-read it, so a store rejected only
// after the fact could not be repaired in-process (#1732).
await PrefsRecovery.assertStoreReadable();
try {
return await _openSharedCache();
} catch (error, stackTrace) {
if (!PrefsRecovery.isCorruptStoreError(error)) rethrow;
// The preflight accepted this document and the plugin still rejected it,
// so it has now cached something we cannot reason about. Repair can
// still quarantine the file, but the process has to restart afterwards.
appLogger.e('Preference store could not be parsed', error: error, stackTrace: stackTrace);
throw CorruptPreferenceStoreException(error, stackTrace, reopenSafe: false);
}
}
static Future<SharedPreferencesWithCache> _openSharedCache() async {
final legacy = await SharedPreferences.getInstance();
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
legacySharedPreferencesInstance: legacy,
@@ -90,6 +112,105 @@ abstract class BaseSharedPreferencesService {
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
}
/// Quarantines an *unparseable* store, opens a fresh one and reseeds every
/// credential that could be salvaged.
///
/// Never call this without an explicit user decision: it resets settings,
/// and any credential that could not be salvaged is gone. The salvaged vault
/// key is written before this future completes, which is what makes the
/// reseed safe — `CredentialVault` memoises the first key it sees, so a
/// single read landing before the seed would generate a replacement and
/// permanently orphan every token stored in the database. Nothing can read
/// preferences until [sharedCache] resolves, so doing the work here closes
/// that window entirely.
///
/// Only valid for [CorruptPreferenceStoreException]. The desktop plugins
/// memoise the parsed document in a private `_cachedPreferences` map and
/// never re-read it without an explicit reload; that map is only empty here
/// because the parse threw before it could be populated. Use
/// [dropUnreadableCredential] for a store that parsed but holds one
/// unreadable value — quarantining that one would reopen onto the stale
/// in-memory map and write the bad value straight back.
static Future<PrefsRepairOutcome> repairCorruptStore({bool reopenSafe = true}) async {
final (:salvaged, :backupPath) = await PrefsRecovery.quarantine();
_resetGeneration++;
_initializations.clear();
_instances.clear();
if (!reopenSafe) {
// The plugin memoised the bad document before it threw, so reopening
// would hand that copy back and the first write would persist it over
// the repaired file. Write the salvage straight to disk for the next
// process instead, and leave this one's store closed.
//
// Nothing may write a preference before that restart or the plugin's
// stale map would overwrite the seed; the caller keeps the app on the
// failure screen precisely so nothing does.
final seeded = await PrefsRecovery.seedStore(salvaged);
appLogger.w('Preference store quarantined; a restart is required before it can be reopened');
return PrefsRepairOutcome(
backupPath: backupPath,
vaultKeySalvaged: seeded && salvaged.vaultKey != null,
sessionsSalvaged: seeded ? salvaged.sessions.length : 0,
sessionsLost: seeded ? salvaged.losses : salvaged.losses + salvaged.sessions.length,
requiresRestart: true,
);
}
_cacheFuture = null;
final repaired = _cacheLoader().then((cache) async {
final vaultKey = salvaged.vaultKey;
if (vaultKey != null) await cache.setString(credentialVaultKeyPref, vaultKey);
for (final entry in salvaged.sessions.entries) {
await cache.setString(entry.key, entry.value);
}
return cache;
});
_cacheFuture = repaired;
await repaired;
return PrefsRepairOutcome(
backupPath: backupPath,
vaultKeySalvaged: salvaged.vaultKey != null,
sessionsSalvaged: salvaged.sessions.length,
sessionsLost: salvaged.losses,
);
}
/// Removes one credential preference whose stored type is unreadable.
///
/// The store itself parsed here, so the plugin's `_cachedPreferences` map is
/// already populated and a quarantine-and-reopen would hand back that stale
/// map and persist the bad value again. Delete through the live cache
/// instead: that updates both the in-memory map and the file, and leaves
/// every other credential in place.
///
/// The file is *copied* first, not moved — it is still the app's live store,
/// and the copy is the only record of the pre-repair state.
static Future<PrefsRepairOutcome> dropUnreadableCredential(String key) async {
final backupPath = await PrefsRecovery.backupStore();
final cache = await sharedCache();
await cache.remove(key);
// Force `onInit` to run again against the repaired store; the cache future
// stays as-is because the store was never reopened.
_resetGeneration++;
_initializations.clear();
_instances.clear();
appLogger.w('Removed unreadable credential preference "$key"');
return PrefsRepairOutcome(
backupPath: backupPath,
// The vault key survives unless it was the unreadable value itself.
vaultKeySalvaged: key != credentialVaultKeyPref,
sessionsSalvaged: 0,
sessionsLost: key == credentialVaultKeyPref ? 0 : 1,
settingsReset: false,
);
}
@visibleForTesting
static void setCacheLoaderForTesting(Future<SharedPreferencesWithCache> Function() loader) {
_cacheFuture = null;
@@ -108,13 +229,52 @@ abstract class BaseSharedPreferencesService {
_cacheLoader = _loadSharedCache;
}
/// Reads a stored value, tolerating one whose type no longer matches the
/// declaration.
///
/// `SharedPreferencesWithCache.getX` is an `as T?` cast, so a value written
/// by an older build, hand-edited, or partially recovered throws `TypeError`
/// rather than returning null. A value we cannot read is indistinguishable
/// from one that was never written, so drop the key and fall back to the
/// declared default instead of letting it propagate — before #1732 a single
/// mistyped preference could fail the entire startup gate.
///
/// Credential slots are exempt: silently dropping one would sign the user
/// out with no explanation. Those raise
/// [UnreadableSensitivePreferenceException], which the startup gate
/// classifies as repairable so the user gets the same consented repair as an
/// unparseable store.
T? _readTolerant<T>(String key, T? Function() read) {
try {
return read();
} on TypeError catch (error, stackTrace) {
if (isSensitivePrefKey(key)) {
appLogger.e('Credential preference "$key" is unreadable', error: error, stackTrace: stackTrace);
Error.throwWithStackTrace(UnreadableSensitivePreferenceException(key, error), stackTrace);
}
appLogger.w('Dropping preference "$key" with an unreadable stored type', error: error, stackTrace: stackTrace);
unawaited(
_cache.remove(key).catchError((Object e, StackTrace s) {
appLogger.d('Could not drop unreadable preference "$key"', error: e, stackTrace: s);
}),
);
return null;
}
}
/// Nullable string read routed through [_readTolerant]. Use instead of
/// `prefs.getString(...)` wherever a mistyped stored value must not throw.
String? readNullableString(String key) => _readTolerant(key, () => _cache.getString(key));
/// Typed read helpers — return the stored value or [defaultValue] when missing.
bool readBool(String key, {bool defaultValue = false}) => _cache.getBool(key) ?? defaultValue;
int readInt(String key, {int defaultValue = 0}) => _cache.getInt(key) ?? defaultValue;
double readDouble(String key, {double defaultValue = 0.0}) => _cache.getDouble(key) ?? defaultValue;
String readString(String key, {String defaultValue = ''}) => _cache.getString(key) ?? defaultValue;
bool readBool(String key, {bool defaultValue = false}) =>
_readTolerant(key, () => _cache.getBool(key)) ?? defaultValue;
int readInt(String key, {int defaultValue = 0}) => _readTolerant(key, () => _cache.getInt(key)) ?? defaultValue;
double readDouble(String key, {double defaultValue = 0.0}) =>
_readTolerant(key, () => _cache.getDouble(key)) ?? defaultValue;
String readString(String key, {String defaultValue = ''}) => readNullableString(key) ?? defaultValue;
List<String> readStringList(String key, {List<String> defaultValue = const []}) =>
_cache.getStringList(key) ?? defaultValue;
_readTolerant(key, () => _cache.getStringList(key)) ?? defaultValue;
/// Typed write helpers — symmetric with the read helpers above; use these
/// instead of `prefs.setX(...)` so call sites stay terse.
@@ -277,7 +437,7 @@ class NullableStringPref extends Pref<String?> {
final String? Function(String?)? transform;
const NullableStringPref(super.key, {this.transform});
@override
String? readFrom(BaseSharedPreferencesService svc) => svc.prefs.getString(key);
String? readFrom(BaseSharedPreferencesService svc) => svc.readNullableString(key);
@override
Future<void> writeTo(BaseSharedPreferencesService svc, String? value) async {
final normalized = transform == null ? value : transform!(value);
@@ -313,7 +473,7 @@ class EnumPref<T extends Enum> extends Pref<T> {
T get _default => defaultValueProvider?.call() ?? defaultValue!;
@override
T readFrom(BaseSharedPreferencesService svc) {
final stored = svc.prefs.getString(key);
final stored = svc.readNullableString(key);
if (stored == null) return _default;
return values.firstWhere((v) => v.name == stored, orElse: () => _default);
}
@@ -332,7 +492,7 @@ class JsonPref<T> extends Pref<T> {
@override
T readFrom(BaseSharedPreferencesService svc) {
final s = svc.prefs.getString(key);
final s = svc.readNullableString(key);
if (s == null) return defaultValue;
try {
return decode(json.decode(s));
+57
View File
@@ -0,0 +1,57 @@
import 'dart:convert';
import '../utils/media_server_http_client.dart';
/// Relay endpoint that returns a short, quotable Log ID.
const String logUploadEndpoint = 'https://ice.plezy.app/logs';
/// Relay `/logs` accepts 1 MiB. The in-memory buffer intentionally remains
/// larger for local viewing and copying; uploads retain the device header and
/// newest log lines within this transport contract.
const int maxLogUploadBytes = 1 * 1024 * 1024;
/// Trims [logs] from the front so `header + logs` fits [maxBytes], keeping the
/// header intact and never splitting a UTF-8 sequence or a log line.
String constrainLogUploadPayload({required String header, required String logs, int maxBytes = maxLogUploadBytes}) {
if (maxBytes <= 0) return '';
final headerBytes = utf8.encode(header);
if (headerBytes.length >= maxBytes) {
var end = maxBytes;
while (end > 0 && end < headerBytes.length && (headerBytes[end] & 0xC0) == 0x80) {
end--;
}
return utf8.decode(headerBytes.sublist(0, end));
}
final logBytes = utf8.encode(logs);
final availableLogBytes = maxBytes - headerBytes.length;
if (logBytes.length <= availableLogBytes) return '$header$logs';
var start = logBytes.length - availableLogBytes;
while (start < logBytes.length && (logBytes[start] & 0xC0) == 0x80) {
start++;
}
final nextLine = logBytes.indexOf(0x0A, start);
if (nextLine >= 0 && nextLine + 1 < logBytes.length) {
start = nextLine + 1;
}
return header + utf8.decode(logBytes.sublist(start));
}
/// Posts [payload] to the log relay and returns the short Log ID.
///
/// Shared by Settings Logs and by the startup failure screen, which cannot
/// reach Settings because the gate it needs has not completed (#1732).
///
/// [payload] must already be redacted and allowlisted by the caller; this
/// function performs no sanitisation of its own.
Future<String> uploadDiagnosticText(String payload, {MediaServerHttpClient? client}) async {
final response = await (client ?? httpClient).post(
logUploadEndpoint,
body: payload,
headers: {'Content-Type': 'text/plain'},
);
final data = response.data is String ? jsonDecode(response.data as String) : response.data;
return (data as Map<String, dynamic>)['id'] as String;
}
+487
View File
@@ -0,0 +1,487 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../models/seerr/seerr_session.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
import 'sensitive_prefs.dart';
import 'trackers/tracker_constants.dart';
import 'trackers/tracker_session.dart';
/// File-backed preference store used by the desktop `shared_preferences`
/// implementations. Windows and Linux both persist a single flat JSON object
/// at `<applicationSupport>/shared_preferences.json`, written with a
/// non-atomic `writeAsStringSync` and parsed with an unguarded `json.decode`.
/// A crash, power loss or antivirus interception mid-write therefore leaves a
/// truncated file that fails every subsequent launch identically.
const String prefsStoreFileName = 'shared_preferences.json';
/// Prefix the legacy `SharedPreferences` API writes into the same store file
/// (`shared_preferences_legacy.dart` `_prefix`). Entries only exist under this
/// spelling until the legacy-to-async migration has copied them across.
const String legacyKeyPrefix = 'flutter.';
/// Raised when the preference store exists but cannot be parsed.
///
/// `BaseSharedPreferencesService` converts the platform's raw
/// `FormatException`/`TypeError` into this so the startup gate can tell a
/// repairable store apart from an inaccessible directory, and offer the user
/// an explicit repair instead of a dead app (#1732).
///
/// The original error is deliberately **not** retained. `json.decode` throws a
/// `FormatException` whose `source` is the entire preference document, and
/// `FormatException.toString()` prints an excerpt of it around the error
/// offset. Anything holding that object could persist, render or upload raw
/// credential material — `credential_vault_key_v1`, a tracker refresh token, a
/// Seerr cookie — and at this point in startup `LogRedactionManager` has no
/// registered values to catch it. Only the cause's type and offset survive,
/// both of which are safe by construction.
class CorruptPreferenceStoreException implements Exception {
CorruptPreferenceStoreException(Object cause, this.causeStackTrace, {this.reopenSafe = true})
: causeType = cause.runtimeType.toString(),
offset = cause is FormatException ? cause.offset : null;
/// Runtime type of the discarded cause, e.g. `FormatException`.
final String causeType;
/// Byte offset the parser failed at, when the cause reported one.
final int? offset;
/// Frames only — never contains document contents.
final StackTrace causeStackTrace;
/// Whether the store can be reopened inside this process after a repair.
///
/// True when [PrefsRecovery.assertStoreReadable] rejected the document
/// before either plugin backend touched it, so nothing was memoised. False
/// when the preflight passed and the plugin failed anyway: the desktop
/// backends cache the parsed document in a private map and never re-read it,
/// so reopening would hand back the bad document and write it straight back.
/// A repair in that state must be followed by a restart.
final bool reopenSafe;
@override
String toString() =>
'CorruptPreferenceStoreException: the preference store could not be parsed'
' ($causeType${offset == null ? '' : ' at offset $offset'})';
}
/// Raised when a credential preference exists but its stored type no longer
/// matches the declaration.
///
/// Ordinary preferences are dropped and defaulted on a type mismatch, but a
/// credential cannot be: silently discarding one would sign the user out with
/// no explanation. This surfaces instead, so the startup gate can offer the
/// same consented repair it offers for an unparseable store (#1732).
///
/// Only the key name and the cause's type are retained. Key names are not
/// secret; values are, and no value ever reaches this object.
class UnreadableSensitivePreferenceException implements Exception {
UnreadableSensitivePreferenceException(this.key, Object cause) : causeType = cause.runtimeType.toString();
final String key;
final String causeType;
@override
String toString() =>
'UnreadableSensitivePreferenceException: credential preference "$key"'
' has an unreadable stored type ($causeType)';
}
/// What a repair recovered and what it could not.
///
/// The distinction is user-facing. A salvaged vault key keeps every
/// server/profile signed in, because those tokens live as ciphertext in the
/// database rather than in preferences. Tracker and Seerr sessions are stored
/// as plaintext preference entries, so they survive only when individually
/// salvageable — the vault key says nothing about them.
class PrefsRepairOutcome {
const PrefsRepairOutcome({
required this.backupPath,
required this.vaultKeySalvaged,
required this.sessionsSalvaged,
required this.sessionsLost,
this.settingsReset = true,
this.requiresRestart = false,
});
/// Absolute path of the quarantined store. It contains credentials in
/// plaintext: never upload it, attach it to a report, or log its contents.
final String? backupPath;
/// Whether [credentialVaultKeyPref] was recovered. When false every stored
/// server and profile token becomes undecryptable and must be re-acquired.
final bool vaultKeySalvaged;
/// Tracker/Seerr session slots reseeded into the fresh store.
final int sessionsSalvaged;
/// Session slots that were present but unrecoverable.
final int sessionsLost;
/// Whether the user has to reconnect at least one tracker or Seerr instance.
bool get sessionsAffected => sessionsLost > 0;
/// Whether the repair discarded the settings store. False for the surgical
/// single-key repair, which leaves every other preference untouched.
final bool settingsReset;
/// Whether the app must restart before the repaired store can be used.
/// Set when the plugin had already memoised the bad document.
final bool requiresRestart;
}
/// Credentials lifted out of a damaged store, before it is quarantined.
class SalvagedPrefsCredentials {
const SalvagedPrefsCredentials({required this.vaultKey, required this.sessions, required this.losses});
static const SalvagedPrefsCredentials empty = SalvagedPrefsCredentials(vaultKey: null, sessions: {}, losses: 0);
/// Base64 vault key, already validated as exactly 32 raw bytes.
final String? vaultKey;
/// Preference key → encoded session payload, each validated by decoding it
/// with the owning store's codec.
final Map<String, String> sessions;
/// Credential slots that were present but could not be decoded.
final int losses;
}
/// Recovers a damaged desktop preference store without silently destroying
/// credentials.
///
/// Repair is never automatic. `SettingsService.getInstance()` surfaces
/// [CorruptPreferenceStoreException] to the startup gate, the gate offers the
/// user an explicit choice that names the real cost, and only an accepted
/// choice reaches `BaseSharedPreferencesService.repairCorruptStore`.
abstract final class PrefsRecovery {
/// Whether [error] means the store is present but unparseable, as opposed to
/// missing, inaccessible, or a plugin failure.
///
/// `shared_preferences_windows` throws `FormatException` from `json.decode`
/// on a truncated file and `TypeError` from `Map<String, Object>.from` when
/// a decoded value is null.
static bool isCorruptStoreError(Object error) => error is FormatException || error is TypeError;
/// Whether a repair can be attempted on this platform. Only the desktop
/// implementations use a single JSON file we can salvage and quarantine;
/// Android, iOS and macOS delegate to platform-native stores.
static bool get isSupportedPlatform => Platform.isWindows || Platform.isLinux;
static Future<File> storeFile() async {
final directory = await getApplicationSupportDirectory();
return File(p.join(directory.path, prefsStoreFileName));
}
/// Rejects a damaged desktop store *before* either plugin backend reads it.
///
/// This ordering is load-bearing. `shared_preferences_windows` assigns the
/// decoded document to a private `_cachedPreferences` map and never re-reads
/// it, and the map it stores is the lazy result of `Map.cast<String, Object>`
/// — so a document containing a null value caches successfully and only
/// throws later, when `Map<String, Object>.from` walks it. Detecting that
/// after the fact would be useless: the bad document is already memoised, a
/// repair could not reopen onto a clean store, and the first write would
/// persist the memoised copy straight back over the repaired file.
///
/// Validating here means the plugin only ever sees a document it can hold.
///
/// No-op on the platforms that use a native store, and on a missing store —
/// a first launch has nothing to validate.
static Future<void> assertStoreReadable({File? storeFileOverride}) async {
if (storeFileOverride == null && !isSupportedPlatform) return;
final File file;
try {
file = storeFileOverride ?? await storeFile();
if (!await file.exists()) return;
} on Object {
// Locating or stat-ing the directory is a different failure entirely
// (missing/denied application support); let the plugin report it.
return;
}
final String raw;
try {
raw = await file.readAsString();
} on FileSystemException {
return; // Unreadable rather than invalid; not something a repair fixes.
} on FormatException catch (error, stackTrace) {
throw CorruptPreferenceStoreException(error, stackTrace); // Not UTF-8.
}
if (raw.isEmpty) return;
final Object? decoded;
try {
decoded = jsonDecode(raw);
} on FormatException catch (error, stackTrace) {
throw CorruptPreferenceStoreException(error, stackTrace);
}
if (decoded is! Map) {
throw CorruptPreferenceStoreException(
const FormatException('Preference store is not a JSON object'),
StackTrace.current,
);
}
for (final entry in decoded.entries) {
if (entry.key is! String || !_isStorableValue(entry.value)) {
// The key name is safe to omit and the value must never be quoted, so
// the exception deliberately carries neither.
throw CorruptPreferenceStoreException(
const FormatException('Preference store holds a value of an unsupported type'),
StackTrace.current,
);
}
}
}
/// Mirrors what the desktop backends can hold: the JSON scalars plus a
/// string list. A null value is the common real-world offender.
static bool _isStorableValue(Object? value) {
if (value is bool || value is int || value is double || value is String) return true;
return value is List && value.every((element) => element is String);
}
/// Writes a fresh store containing only [salvaged], for the next process.
///
/// Used when the plugin already memoised the bad document and cannot be
/// reopened in-process. The salvage would otherwise be thrown away: the
/// quarantined file is gone, nothing reseeds the vault key, and the next
/// launch generates a replacement key that orphans every token stored as
/// ciphertext in the database.
///
/// Writes the same flat JSON object the desktop backends read, under the
/// unprefixed async key names. Staged through a sibling temporary file and
/// renamed into place so a crash mid-write cannot leave a second truncated
/// store — the exact failure this whole path exists to recover from.
///
/// Returns whether the seed landed.
static Future<bool> seedStore(SalvagedPrefsCredentials salvaged, {File? storeFileOverride}) async {
final vaultKey = salvaged.vaultKey;
if (vaultKey == null && salvaged.sessions.isEmpty) return false;
final values = <String, Object>{credentialVaultKeyPref: ?vaultKey, ...salvaged.sessions};
try {
final file = storeFileOverride ?? await storeFile();
if (!await file.parent.exists()) await file.parent.create(recursive: true);
final staged = File('${file.path}.seed');
await staged.writeAsString(jsonEncode(values), flush: true);
await staged.rename(file.path);
return true;
} catch (error, stackTrace) {
appLogger.e('Could not seed a repaired preference store', error: error, stackTrace: stackTrace);
return false;
}
}
/// Lifts credentials out of the raw bytes of a store that no longer parses.
///
/// A damaged store is almost always *truncated*, not scrambled, so entries
/// near the front usually survive verbatim even when the object as a whole
/// has no closing brace. Each value is matched on the wire, unescaped
/// individually and validated with its owning codec, so a partially written
/// entry is discarded rather than reseeded as garbage.
///
/// Both key spellings are recognised. The legacy `SharedPreferences` API
/// persists into the same file under a [legacyKeyPrefix] prefix, and
/// `_loadSharedCache` runs `SharedPreferences.getInstance()` before the
/// legacy-to-async migration — so a store damaged mid-migration can hold a
/// credential only under its prefixed name. Salvage normalises those onto
/// the async key the app actually reads, and an unprefixed entry always wins
/// over a prefixed one regardless of their order in the file.
@visibleForTesting
static SalvagedPrefsCredentials salvage(String raw) {
String? vaultKey;
var vaultKeyFromLegacy = false;
final sessions = <String, String>{};
final sessionFromLegacy = <String, bool>{};
var losses = 0;
for (final match in _stringEntryPattern.allMatches(raw)) {
final rawKey = _unescape(match.group(1)!);
if (rawKey == null) continue;
final legacy = rawKey.startsWith(legacyKeyPrefix);
final key = legacy ? rawKey.substring(legacyKeyPrefix.length) : rawKey;
if (!isSensitivePrefKey(key)) continue;
final value = _unescape(match.group(2)!);
if (key == credentialVaultKeyPref) {
if (value == null || !_isVaultKey(value)) {
losses++;
} else if (vaultKey == null || (vaultKeyFromLegacy && !legacy)) {
vaultKey = value;
vaultKeyFromLegacy = legacy;
}
continue;
}
// The legacy Plex slot is an opaque token with no codec to validate
// against; a non-empty string is all we can assert.
final valid =
value != null &&
value.isNotEmpty &&
(key == legacyPlexTokenPref ? _registerLegacyPlexToken(value) : _validateAndRegisterSession(key, value));
if (!valid) {
losses++;
continue;
}
if (!sessions.containsKey(key) || ((sessionFromLegacy[key] ?? false) && !legacy)) {
sessions[key] = value;
sessionFromLegacy[key] = legacy;
}
// Defence in depth on top of the per-field registration above: catches
// the payload being echoed whole.
LogRedactionManager.registerCustomValue(value);
}
if (vaultKey != null) {
// Register before returning so no later log line, diagnostic or crash
// report can echo the key material even if a caller mishandles it.
LogRedactionManager.registerCustomValue(vaultKey);
}
return SalvagedPrefsCredentials(vaultKey: vaultKey, sessions: sessions, losses: losses);
}
/// Copies the live store aside without disturbing it.
///
/// Used by the surgical single-key repair, where the store is still valid
/// and stays in place — only the copy records the pre-repair state.
static Future<String?> backupStore({File? storeFileOverride}) async {
final file = storeFileOverride ?? await storeFile();
if (!await file.exists()) return null;
final backup = File(p.join(file.parent.path, 'shared_preferences.backup-${_stamp()}.json'));
try {
await file.copy(backup.path);
} on FileSystemException catch (error, stackTrace) {
appLogger.w('Could not back up the preference store before repair', error: error, stackTrace: stackTrace);
return null;
}
return backup.path;
}
static String _stamp() => DateTime.now().toUtc().toIso8601String().replaceAll(RegExp(r'[:.]'), '-');
/// Quarantines the damaged store and returns what was salvaged.
///
/// The caller opens a fresh store afterwards and reseeds it; see
/// `BaseSharedPreferencesService.repairCorruptStore`.
///
/// The damaged file is *moved*, never deleted: it is the only remaining copy
/// of any credential that could not be salvaged.
static Future<({SalvagedPrefsCredentials salvaged, String? backupPath})> quarantine({File? storeFileOverride}) async {
final file = storeFileOverride ?? await storeFile();
if (!await file.exists()) {
return (salvaged: SalvagedPrefsCredentials.empty, backupPath: null);
}
String raw;
try {
raw = await file.readAsString();
} on FileSystemException {
rethrow;
} on FormatException {
// Not valid UTF-8 either; a lossy read still exposes the ASCII-only
// credential entries to the salvage pass.
raw = const Utf8Decoder(allowMalformed: true).convert(await file.readAsBytes());
}
final salvaged = salvage(raw);
final stamp = _stamp();
final backup = File(p.join(file.parent.path, 'shared_preferences.corrupt-$stamp.json'));
try {
await file.rename(backup.path);
} on FileSystemException catch (error, stackTrace) {
// Cross-device or locked; copy-then-delete keeps the bytes rather than
// failing the repair outright.
appLogger.w(
'Preference store quarantine could not rename; copying instead',
error: error,
stackTrace: stackTrace,
);
await file.copy(backup.path);
await file.delete();
}
appLogger.w(
'Quarantined a corrupt preference store'
' (vault key salvaged: ${salvaged.vaultKey != null},'
' sessions salvaged: ${salvaged.sessions.length}, lost: ${salvaged.losses})',
);
return (salvaged: salvaged, backupPath: backup.path);
}
/// Deletes a quarantined store so the user is not left holding a
/// credential-bearing file indefinitely.
static Future<void> deleteBackup(String path) async {
final file = File(path);
if (await file.exists()) await file.delete();
}
// A `"key": "value"` pair with JSON-escaped halves. Deliberately tolerant of
// the surrounding object being unterminated.
static final RegExp _stringEntryPattern = RegExp(r'"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"');
/// The vault generates exactly 32 random bytes; anything else would make
/// `AesGcm.with256bits()` throw on first use, which is worse than no key.
static bool _isVaultKey(String value) {
if (value.isEmpty) return false;
try {
return base64Decode(value).length == 32;
} catch (_) {
return false;
}
}
/// The legacy Plex slot is an opaque bearer token. Registering it as a token
/// also covers its URL-encoded form.
static bool _registerLegacyPlexToken(String value) {
LogRedactionManager.registerToken(value);
return true;
}
/// Validates a session payload with its owning codec and registers every
/// secret it carries individually.
///
/// Registering only the encoded payload would redact it just when it appears
/// verbatim; a bare access token, refresh token or session cookie quoted on
/// its own would still leak. The decode step already hands us the fields, so
/// harvest them while they are in scope.
static bool _validateAndRegisterSession(String key, String encoded) {
try {
if (isSeerrSessionPrefKey(key)) {
final session = SeerrSession.decode(encoded);
LogRedactionManager.registerToken(session.cookie);
// `secret` is CredentialVault ciphertext at rest; the plaintext is not
// available here, so register the stored form.
LogRedactionManager.registerCustomValue(session.secret);
LogRedactionManager.registerServerUrl(session.baseUrl);
return true;
}
final base = profileScopedCredentialBaseKey(key);
final service = TrackerService.values.where((s) => base == '${s.name}_session').firstOrNull;
if (service == null) return false;
final session = TrackerSession.decode(encoded, service: service);
LogRedactionManager.registerToken(session.accessToken);
LogRedactionManager.registerToken(session.refreshToken);
return true;
} catch (_) {
return false;
}
}
static String? _unescape(String rawInner) {
try {
return jsonDecode('"$rawInner"') as String;
} catch (_) {
return null;
}
}
}
+79
View File
@@ -0,0 +1,79 @@
/// Preference keys whose stored values are credentials.
///
/// `shared_preferences` is the most credential-dense artifact in a Plezy
/// installation. On the desktop platforms it is a single plaintext JSON file
/// next to the database, and it holds:
///
/// * [credentialVaultKeyPref] — the AES-256 key that `CredentialVault` uses to
/// protect every server/profile token stored in the Drift
/// `connections.config_json` and `profile_connections.user_token` columns.
/// Losing it orphans every one of those ciphertexts permanently.
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
/// verbatim, so raw OAuth `access_token`/`refresh_token` pairs for MAL,
/// AniList, Simkl and Trakt live here in plaintext.
/// * Seerr sessions — `SeerrSessionStore` persists a raw `connect.sid` cookie
/// alongside a vault-protected password.
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
/// drained by the connection migration but can linger on old installs.
///
/// Two subsystems consult this list, both added for #1732:
///
/// * the tolerant preference reads in `BaseSharedPreferencesService` must never
/// silently drop one of these keys — an unreadable credential has to surface
/// as an explicit repair prompt, not as a silent re-authentication;
/// * the corrupt-store repair in `PrefsRecovery` salvages exactly these keys
/// out of a damaged store before quarantining it.
///
/// Keep this list exhaustive. A credential slot that is missing here is
/// silently dropped on a type mismatch and silently lost on a repair.
///
/// This lives apart from `CredentialVault`, `TrackerAccountStore` and
/// `SeerrSessionStore` so `BaseSharedPreferencesService` can depend on it
/// without an import cycle.
library;
/// Key holding the base64 `CredentialVault` AES-256 key.
const String credentialVaultKeyPref = 'credential_vault_key_v1';
/// Legacy single-slot Plex token, superseded by the connection registry.
const String legacyPlexTokenPref = 'plex_token';
/// Unscoped base keys used by `TrackerAccountStore`, one per tracker service.
const List<String> trackerSessionBaseKeys = <String>[
'mal_session',
'anilist_session',
'simkl_session',
'trakt_session',
];
/// Unscoped base key used by `SeerrSessionStore`.
const String seerrSessionBaseKey = 'seerr_session';
/// Every credential slot that is profile-scoped through `profileScopedPrefsKey`,
/// so a stored key is either the bare base key or `user_{scope}_{baseKey}`.
const List<String> profileScopedCredentialBaseKeys = <String>[...trackerSessionBaseKeys, seerrSessionBaseKey];
final RegExp _profileScopedCredentialPattern = RegExp(
'^(?:user_.+_)?(?:${profileScopedCredentialBaseKeys.join('|')})\$',
);
/// The unscoped base key [key] resolves to, or null when [key] is not a
/// profile-scoped credential slot.
String? profileScopedCredentialBaseKey(String key) {
if (!_profileScopedCredentialPattern.hasMatch(key)) return null;
for (final base in profileScopedCredentialBaseKeys) {
if (key == base || key.endsWith('_$base')) return base;
}
return null;
}
/// Whether [key] is a profile-scoped or global tracker session slot.
bool isTrackerSessionPrefKey(String key) => trackerSessionBaseKeys.contains(profileScopedCredentialBaseKey(key));
/// Whether [key] is a profile-scoped or global Seerr session slot.
bool isSeerrSessionPrefKey(String key) => profileScopedCredentialBaseKey(key) == seerrSessionBaseKey;
/// Whether [key] holds a credential and must never be dropped or exported
/// without an explicit, informed user decision.
bool isSensitivePrefKey(String key) =>
key == credentialVaultKeyPref || key == legacyPlexTokenPref || profileScopedCredentialBaseKey(key) != null;
+1 -1
View File
@@ -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) {
+290
View File
@@ -0,0 +1,290 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../utils/app_logger.dart';
import '../utils/log_redaction_manager.dart';
/// Named steps of the startup gate.
///
/// The gate used to report a bare `error.runtimeType` with no indication of
/// which step failed, and `Future.wait` discarded every error but the first,
/// so even that was ambiguous between four concurrent steps (#1732). Every
/// step now carries a stable identifier that reaches the failure screen, the
/// log, the persisted record and Sentry.
enum StartupPhase {
preferences('preferences'),
crashReporting('crash-reporting'),
locale('locale'),
windowManager('window-manager'),
deviceCapabilities('device-capabilities'),
storage('storage'),
database('database'),
imageCache('image-cache'),
downloadStorage('download-storage');
const StartupPhase(this.id);
/// Stable wire/log identifier. Do not rename: persisted records and Sentry
/// tags are matched on it.
final String id;
static StartupPhase? fromId(String? id) =>
id == null ? null : StartupPhase.values.where((phase) => phase.id == id).firstOrNull;
}
/// Tags a startup failure with the gate phase it came from.
///
/// Transparent by design: [cause] is the original error, so existing
/// classification (`isStorageFullError`, corrupt-store detection) keeps working
/// on `exception.cause` and the reported runtime type stays the real one.
class StartupPhaseException implements Exception {
const StartupPhaseException(this.phase, this.cause);
final StartupPhase phase;
final Object cause;
/// Unwraps nested wrappers so callers always classify the real error.
static Object unwrap(Object error) {
var current = error;
while (current is StartupPhaseException) {
current = current.cause;
}
return current;
}
static StartupPhase? phaseOf(Object error) => error is StartupPhaseException ? error.phase : null;
@override
String toString() => 'StartupPhaseException(${phase.id}): ${StartupFailureRecord.describeErrorSafely(cause)}';
}
/// A startup-gate failure, reduced to an allowlist of fields that are safe to
/// show, copy, persist and upload.
///
/// Redaction is defence in depth here, not the mechanism: nothing derived from
/// preference contents, database rows or file bytes is ever placed in a
/// record. That matters because `LogRedactionManager`'s registered-value set is
/// seeded by `StorageService.onInit`, which runs *inside* the gate — a failure
/// at or before that step leaves only the pattern matcher active.
class StartupFailureRecord {
StartupFailureRecord({
required this.phase,
required this.errorType,
required String message,
required String? stackTrace,
required this.timestamp,
required this.appVersion,
required this.platform,
this.repairable = false,
}) : message = LogRedactionManager.redact(message),
stackTrace = stackTrace == null ? null : LogRedactionManager.redact(stackTrace);
/// Builds a record from a thrown [error].
///
/// [StartupPhaseException] wrappers are unwrapped so the recorded type and
/// message describe the real failure, and [phase] defaults to the one the
/// wrapper carries.
factory StartupFailureRecord.fromError({
required Object error,
required StackTrace? stackTrace,
required String appVersion,
required String platform,
StartupPhase? phase,
bool repairable = false,
DateTime? timestamp,
}) {
final cause = StartupPhaseException.unwrap(error);
return StartupFailureRecord(
phase: phase ?? StartupPhaseException.phaseOf(error),
errorType: cause.runtimeType.toString(),
message: describeErrorSafely(cause),
stackTrace: stackTrace?.toString(),
timestamp: timestamp ?? DateTime.now(),
appVersion: appVersion,
platform: platform,
repairable: repairable,
);
}
/// Renders [error] without the payload some exception types embed.
///
/// `FormatException.toString()` prints an excerpt of `source` around
/// `offset`, and during startup that source is very often a document we must
/// never surface: the preference store holds the credential-vault key,
/// tracker refresh tokens and Seerr cookies in plaintext. Field-pattern
/// redaction cannot be relied on here because the registered-value set is
/// seeded inside the gate that just failed. Keep the parser's own message
/// and offset, drop the excerpt.
@visibleForTesting
static String describeErrorSafely(Object error) {
final cause = StartupPhaseException.unwrap(error);
if (cause is! FormatException) return cause.toString();
final offset = cause.offset;
final message = cause.message.isEmpty ? 'FormatException' : cause.message;
return offset == null ? message : '$message (at offset $offset)';
}
final StartupPhase? phase;
final String errorType;
/// Already redacted by the constructor.
final String message;
/// Already redacted by the constructor.
final String? stackTrace;
final DateTime timestamp;
final String appVersion;
final String platform;
/// Whether the gate can offer an in-app repair for this failure.
final bool repairable;
String get phaseId => phase?.id ?? 'unknown';
/// One-line summary for the failure screen and the log.
String get headline => '[$phaseId] $errorType: $message';
/// Full plain-text block for the clipboard and the diagnostics upload.
String describe() {
final buffer = StringBuffer()
..writeln('Plezy startup failure')
..writeln('Version: $appVersion')
..writeln('Platform: $platform')
..writeln('When: ${timestamp.toUtc().toIso8601String()}')
..writeln('Phase: $phaseId')
..writeln('Error: $errorType')
..writeln('Message: $message');
final stack = stackTrace;
if (stack != null && stack.isNotEmpty) {
buffer
..writeln('Stack trace:')
..writeln(stack);
}
return buffer.toString().trimRight();
}
Map<String, Object?> toJson() => {
'phase': phase?.id,
'errorType': errorType,
'message': message,
'stackTrace': stackTrace,
'timestamp': timestamp.toUtc().toIso8601String(),
'appVersion': appVersion,
'platform': platform,
'repairable': repairable,
};
static StartupFailureRecord? fromJson(Map<String, Object?> json) {
final message = json['message'];
final errorType = json['errorType'];
final timestamp = DateTime.tryParse(json['timestamp'] as String? ?? '');
if (message is! String || errorType is! String || timestamp == null) return null;
return StartupFailureRecord(
phase: StartupPhase.fromId(json['phase'] as String?),
errorType: errorType,
message: message,
stackTrace: json['stackTrace'] as String?,
timestamp: timestamp,
appVersion: json['appVersion'] as String? ?? 'unknown',
platform: json['platform'] as String? ?? 'unknown',
repairable: json['repairable'] as bool? ?? false,
);
}
}
/// Persists the most recent startup-gate failure so it survives the process.
///
/// A failing launch has no other egress: the log buffer is in memory only, a
/// GUI-launched Windows release build has no console, and the in-app log
/// viewer sits behind the gate that just failed. Writing one small record next
/// to the database lets the next *successful* launch surface it in
/// Settings Logs, where the user can upload it (#1732).
///
/// The record is an allowlist of already-redacted fields; raw store contents
/// never reach it.
abstract final class StartupDiagnosticsStore {
static const String fileName = 'startup_failure.json';
@visibleForTesting
static Directory? debugDirectoryOverride;
static StartupFailureRecord? _pending;
/// Record observed during this launch, if any. Set both when a failure is
/// recorded and when one written by an earlier launch is consumed.
static StartupFailureRecord? get pending => _pending;
static Future<File?> _file() async {
try {
final directory = debugDirectoryOverride ?? await getApplicationSupportDirectory();
return File(p.join(directory.path, fileName));
} catch (error, stackTrace) {
appLogger.d('Startup diagnostics location unavailable', error: error, stackTrace: stackTrace);
return null;
}
}
/// Best-effort write. A diagnostics failure must never worsen the failure it
/// is describing, so every error here is logged and swallowed.
static Future<void> record(StartupFailureRecord failure) async {
_pending = failure;
try {
final file = await _file();
if (file == null) return;
if (!await file.parent.exists()) await file.parent.create(recursive: true);
await file.writeAsString(jsonEncode(failure.toJson()), flush: true);
} catch (error, stackTrace) {
appLogger.d('Could not persist the startup failure record', error: error, stackTrace: stackTrace);
}
}
/// Reads and deletes a record written by an earlier launch.
///
/// Deleting on read stops one stale failure from following the user forever;
/// the value stays in [pending] for the rest of the session so the logs
/// screen can still show it after the user navigates away and back.
static Future<StartupFailureRecord?> consumePrevious() async {
try {
final file = await _file();
if (file == null || !await file.exists()) return null;
final raw = await file.readAsString();
await file.delete();
final decoded = jsonDecode(raw);
if (decoded is! Map) return null;
final record = StartupFailureRecord.fromJson(decoded.cast<String, Object?>());
if (record != null) _pending = record;
return record;
} catch (error, stackTrace) {
appLogger.d('Could not read a previous startup failure record', error: error, stackTrace: stackTrace);
return null;
}
}
/// Drops a persisted record without surfacing it in [pending].
static Future<void> clear() async {
_pending = null;
try {
final file = await _file();
if (file != null && await file.exists()) await file.delete();
} catch (error, stackTrace) {
appLogger.d('Could not clear the startup failure record', error: error, stackTrace: stackTrace);
}
}
@visibleForTesting
static void resetForTesting() {
_pending = null;
debugDirectoryOverride = null;
}
/// Seeds [pending] without touching disk, for widget tests. The widget-test
/// binding runs in a fake-async zone where a `dart:io` future never
/// completes, so [record] cannot be awaited from one.
@visibleForTesting
static void setPendingForTesting(StartupFailureRecord? failure) => _pending = failure;
}
+6 -11
View File
@@ -141,7 +141,7 @@ class StorageService extends BaseSharedPreferencesService {
String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration<String>(
baseKey,
prefix: _userPrefix,
read: prefs.getString,
read: readNullableString,
write: prefs.setString,
);
@@ -167,7 +167,7 @@ class StorageService extends BaseSharedPreferencesService {
'Only ConnectionBootstrap.migrateLegacyPlexAccount may use this.',
)
String? getPlexToken() {
return prefs.getString(_keyPlexToken);
return readNullableString(_keyPlexToken);
}
/// Drop the legacy `plex_token` slot. Called by
@@ -257,14 +257,9 @@ class StorageService extends BaseSharedPreferencesService {
}
String? getLibraryTab(String sectionId) {
final key = '$_userPrefix$_prefixLibraryTab$sectionId';
// Handle migration from old int storage: try string first, fall back to removing stale int
try {
return prefs.getString(key);
} catch (_) {
prefs.remove(key);
return null;
}
// Older builds stored this as an int; `readNullableString` drops a value it
// cannot read and falls back to null, which is the correct behaviour here.
return readNullableString('$_userPrefix$_prefixLibraryTab$sectionId');
}
// Hidden Libraries (stored as JSON array of library section IDs)
@@ -285,7 +280,7 @@ class StorageService extends BaseSharedPreferencesService {
_readScopedWithLegacyMigration<String>(
_keyHiddenLibraries,
prefix: _userPrefixForProfileId(profileId),
read: prefs.getString,
read: readNullableString,
write: prefs.setString,
// Only the active profile may adopt the legacy unscoped value. Otherwise
// merely opening another profile's scoped provider could steal legacy