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:
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
@@ -19,6 +18,8 @@ import '../../utils/dialogs.dart';
|
||||
import '../../main.dart' show gitCommit;
|
||||
import '../../services/background_work_diagnostics_service.dart';
|
||||
import '../../services/device_performance.dart';
|
||||
import '../../services/log_upload_service.dart';
|
||||
import '../../services/startup_diagnostics.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -26,37 +27,7 @@ import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
||||
|
||||
/// Relay `/logs` accepts 1 MiB. The in-memory buffer intentionally remains
|
||||
/// larger for local viewing and copying; uploads retain the device header and
|
||||
/// newest log lines within this transport contract.
|
||||
const int maxLogUploadBytes = 1 * 1024 * 1024;
|
||||
|
||||
String constrainLogUploadPayload({required String header, required String logs, int maxBytes = maxLogUploadBytes}) {
|
||||
if (maxBytes <= 0) return '';
|
||||
|
||||
final headerBytes = utf8.encode(header);
|
||||
if (headerBytes.length >= maxBytes) {
|
||||
var end = maxBytes;
|
||||
while (end > 0 && end < headerBytes.length && (headerBytes[end] & 0xC0) == 0x80) {
|
||||
end--;
|
||||
}
|
||||
return utf8.decode(headerBytes.sublist(0, end));
|
||||
}
|
||||
|
||||
final logBytes = utf8.encode(logs);
|
||||
final availableLogBytes = maxBytes - headerBytes.length;
|
||||
if (logBytes.length <= availableLogBytes) return '$header$logs';
|
||||
|
||||
var start = logBytes.length - availableLogBytes;
|
||||
while (start < logBytes.length && (logBytes[start] & 0xC0) == 0x80) {
|
||||
start++;
|
||||
}
|
||||
final nextLine = logBytes.indexOf(0x0A, start);
|
||||
if (nextLine >= 0 && nextLine + 1 < logBytes.length) {
|
||||
start = nextLine + 1;
|
||||
}
|
||||
return header + utf8.decode(logBytes.sublist(start));
|
||||
}
|
||||
const previousStartupFailureKey = Key('logs-previous-startup-failure');
|
||||
|
||||
class LogsScreen extends StatefulWidget {
|
||||
const LogsScreen({super.key, this.httpClient, this.deviceInfoPlugin});
|
||||
@@ -152,7 +123,20 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
return '$hour:$minute:$second.$millisecond';
|
||||
}
|
||||
|
||||
void _clearLogs() {
|
||||
/// Whether there is anything worth copying, uploading or clearing.
|
||||
///
|
||||
/// A launch that failed the startup gate leaves an empty in-memory buffer —
|
||||
/// that process is gone — but does leave a persisted record. Gating the
|
||||
/// actions on the buffer alone would show that record and then refuse to let
|
||||
/// the user do anything with it, which is the whole point of #1732.
|
||||
bool get _hasDiagnostics => _logs.isNotEmpty || StartupDiagnosticsStore.pending != null;
|
||||
|
||||
Future<void> _clearLogs() async {
|
||||
// The startup record is part of the same diagnostic payload, so clearing
|
||||
// logs drops it too, or it would silently reappear in the next upload.
|
||||
// Awaited before rebuilding so the banner cannot survive the clear.
|
||||
await StartupDiagnosticsStore.clear();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
MemoryLogOutput.clearLogs();
|
||||
_logs = [];
|
||||
@@ -161,7 +145,14 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
}
|
||||
|
||||
String _formatAllLogs({int? maxBytes}) {
|
||||
final header = _deviceInfo.isEmpty ? '' : '$_deviceInfo\n---\n';
|
||||
final sections = <String>[
|
||||
if (_deviceInfo.isNotEmpty) _deviceInfo,
|
||||
// A launch that failed the startup gate leaves no in-memory log at all —
|
||||
// the buffer died with that process. This is the only place its record
|
||||
// can reach a maintainer (#1732).
|
||||
?StartupDiagnosticsStore.pending?.describe(),
|
||||
];
|
||||
final header = sections.isEmpty ? '' : '${sections.join('\n\n')}\n---\n';
|
||||
final logs = StringBuffer();
|
||||
var isFirst = true;
|
||||
for (final log in _logs.reversed) {
|
||||
@@ -195,18 +186,11 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
showLoadingDialog(context);
|
||||
|
||||
try {
|
||||
final response = await _httpClient.post(
|
||||
'https://ice.plezy.app/logs',
|
||||
body: logText,
|
||||
headers: {'Content-Type': 'text/plain'},
|
||||
);
|
||||
final id = await uploadDiagnosticText(logText, client: _httpClient);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(); // dismiss loading
|
||||
|
||||
final data = response.data is String ? jsonDecode(response.data) : response.data;
|
||||
final id = (data as Map<String, dynamic>)['id'] as String;
|
||||
|
||||
unawaited(
|
||||
showScopedDialog<void>(
|
||||
context: context,
|
||||
@@ -330,6 +314,50 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
return spans;
|
||||
}
|
||||
|
||||
/// Banner for a startup failure recorded by an earlier launch.
|
||||
///
|
||||
/// Returns null when there is none, so the caller can splice it in with a
|
||||
/// null-aware element.
|
||||
Widget? _buildPreviousFailureBanner(ThemeData theme) {
|
||||
final failure = StartupDiagnosticsStore.pending;
|
||||
if (failure == null) return null;
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
key: previousStartupFailureKey,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(8)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AppIcon(Symbols.error_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.startup.previousFailureTitle,
|
||||
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
failure.describe(),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -369,22 +397,26 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
||||
FocusableAction(
|
||||
icon: Symbols.upload_rounded,
|
||||
tooltip: t.logs.uploadLogs,
|
||||
onPressed: _logs.isNotEmpty ? _uploadLogs : null,
|
||||
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.content_copy_rounded,
|
||||
tooltip: t.logs.copyLogs,
|
||||
onPressed: _logs.isNotEmpty ? _copyAllLogs : null,
|
||||
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_outline_rounded,
|
||||
tooltip: t.logs.clearLogs,
|
||||
onPressed: _logs.isNotEmpty ? _clearLogs : null,
|
||||
onPressed: _hasDiagnostics ? _clearLogs : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// A launch that failed the startup gate leaves nothing in the
|
||||
// in-memory buffer — that process is gone. Show its record
|
||||
// here, where the user can actually act on it (#1732).
|
||||
?_buildPreviousFailureBanner(theme),
|
||||
if (_logs.isEmpty)
|
||||
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user