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
291 lines
11 KiB
Dart
291 lines
11 KiB
Dart
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;
|
||
}
|