Files
xianyan/lib/core/services/catcher2_config_service.dart
Developer 6119918185 release: bump version to 6.6.25+2606241
主要变更:
1. 新增桌面端托盘图标支持深色/浅色主题切换
2. 重构应用锁、动画配置、小组件导航服务职责
3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题
4. 优化诗词服务、阅读进度、搜索结果空状态体验
5. 完善macOS打包配置与错误静默处理逻辑
6. 新增快速卡片多语言适配与动画退出队列管理
2026-06-24 04:26:50 +08:00

331 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — Catcher2 配置服务
/// 创建时间: 2026-05-21
/// 更新时间: 2026-06-23
/// 作用: 统一管理 Catcher2 异常捕获开关与动态配置更新
/// 上次更新: 增加 Flutter 框架内部已知非致命错误过滤,防止弹窗打扰
/// ============================================================
import 'package:catcher_2/catcher_2.dart';
import 'package:catcher_2/model/platform_type.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' show SelectableText;
import 'package:flutter/services.dart';
import '../router/app_router.dart' show rootNavigatorKey;
import '../storage/kv_storage.dart';
import '../utils/logger.dart';
import 'crash_log_service.dart';
class Catcher2ConfigService {
Catcher2ConfigService._();
static final Catcher2ConfigService instance = Catcher2ConfigService._();
Catcher2? _catcher2;
bool _initialized = false;
bool get isInitialized => _initialized;
bool get isEnabled {
return KvStorage.getBool('general_catcher_enabled') ?? true;
}
/// Flutter 框架内部已知非致命错误模式
/// 这些错误由 Flutter 框架自身抛出,非应用代码问题,静默处理避免打扰用户
static const _silentFrameworkErrorPatterns = [
// 布局溢出:非致命,仅记录日志
'overflowed',
'RenderFlex',
// IOSScrollViewFlingVelocityTracker 时间戳乱序Flutter 框架已知 bug
// macOS/iOS 上触摸事件时间戳偶尔乱序导致 velocity tracker 断言失败
// 参考: https://github.com/flutter/flutter/issues/142333
'smaller timestamp',
'predecessor',
];
/// 判断错误是否为 Flutter 框架内部已知非致命错误(应静默处理)
static bool _isSilentFrameworkError(Object? error) {
final errorStr = error?.toString() ?? '';
return _silentFrameworkErrorPatterns.any((p) => errorStr.contains(p));
}
/// 初始化 Catcher2使用 rootWidget 而非 runAppFunction避免 Zone mismatch
/// [screenshotsPath] 截图保存路径,非空时启用截图;为空时 Catcher2 会输出 WARNING
void init({required Widget rootWidget, String screenshotsPath = ''}) {
final enabled = isEnabled;
final debugConfig = enabled
? Catcher2Options(
CopyableDialogReportMode(),
[_ConsoleLogHandler()],
localizationOptions: [
LocalizationOptions.buildDefaultChineseOptions(),
],
screenshotsPath: screenshotsPath,
)
: Catcher2Options(SilentReportMode(), [], screenshotsPath: screenshotsPath);
final releaseConfig = enabled
? Catcher2Options(
SilentReportMode(),
[_ConsoleLogHandler()],
screenshotsPath: screenshotsPath,
)
: Catcher2Options(SilentReportMode(), [], screenshotsPath: screenshotsPath);
// 使用 rootWidget 而非 runAppFunctionCatcher2 会在内部调用 runApp
// 但不会创建新的 Zone避免 Zone mismatch 警告
_catcher2 = Catcher2(
rootWidget: rootWidget,
debugConfig: debugConfig,
releaseConfig: releaseConfig,
profileConfig: Catcher2Options(SilentReportMode(), []),
navigatorKey: rootNavigatorKey,
);
_initialized = true;
Log.i(
'Catcher2ConfigService: 初始化完成 (enabled=$enabled, mode=${kDebugMode ? "debug" : "release"})',
);
}
void updateFromSettings(bool enabled) {
if (_catcher2 == null) {
Log.w('Catcher2ConfigService: Catcher2 未初始化,跳过更新');
return;
}
final debugConfig = enabled
? Catcher2Options(
CopyableDialogReportMode(),
[_ConsoleLogHandler()],
localizationOptions: [
LocalizationOptions.buildDefaultChineseOptions(),
],
)
: Catcher2Options(SilentReportMode(), []);
final releaseConfig = enabled
? Catcher2Options(SilentReportMode(), [_ConsoleLogHandler()])
: Catcher2Options(SilentReportMode(), []);
_catcher2!.updateConfig(
debugConfig: debugConfig,
releaseConfig: releaseConfig,
);
Log.i('Catcher2ConfigService: 配置已更新 (enabled=$enabled)');
}
}
class CopyableDialogReportMode extends ReportMode {
@override
void requestAction(Report report, BuildContext? context) {
_showDialog(report, context);
}
Future<void> _showDialog(Report report, BuildContext? context) async {
// Flutter 框架内部已知非致命错误不弹窗(布局溢出/velocity tracker 等)
if (Catcher2ConfigService._isSilentFrameworkError(report.error)) {
onActionConfirmed(report); // 自动确认,不弹窗
return;
}
await Future<void>.delayed(Duration.zero);
if (context != null && context.mounted) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => _CopyableErrorDialog(
report: report,
onAccept: () {
onActionConfirmed(report);
Navigator.of(ctx).pop();
},
onReject: () {
onActionRejected(report);
Navigator.of(ctx).pop();
},
),
);
}
}
@override
bool isContextRequired() => true;
@override
List<PlatformType> getSupportedPlatforms() => PlatformType.values.toList();
}
class _CopyableErrorDialog extends StatelessWidget {
const _CopyableErrorDialog({
required this.report,
required this.onAccept,
required this.onReject,
});
final Report report;
final VoidCallback onAccept;
final VoidCallback onReject;
String get _errorId {
final ts = report.dateTime.millisecondsSinceEpoch
.toRadixString(36)
.toUpperCase();
return 'ERR-$ts';
}
String get _errorText {
final buffer = StringBuffer();
buffer.writeln('[$_errorId] ${report.dateTime.toIso8601String()}');
buffer.writeln();
buffer.writeln('Error: ${report.error}');
buffer.writeln();
buffer.writeln('Stack Trace:');
buffer.writeln(report.stackTrace);
return buffer.toString();
}
void _copyToClipboard(BuildContext context) {
// 限制剪贴板内容不超过400字避免超长文本占用剪贴板
final text = _errorText;
final clipboardText = text.length > 400
? '${text.substring(0, 400)}...'
: text;
Clipboard.setData(ClipboardData(text: clipboardText));
HapticFeedback.lightImpact();
showCupertinoDialog<void>(
context: context,
barrierDismissible: true,
builder: (ctx) => CupertinoAlertDialog(
content: const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('已复制到剪贴板', style: TextStyle(fontSize: 14)),
),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('OK'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final errorStr = report.error.toString();
final displayError = errorStr.length > 2000
? '${errorStr.substring(0, 2000)}...'
: errorStr;
return CupertinoAlertDialog(
title: Column(
children: [
const Text('App Error'),
const SizedBox(height: 4),
Text(
_errorId,
style: TextStyle(
fontSize: 11,
color: CupertinoColors.secondaryLabel.resolveFrom(context),
),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: SelectableText(
displayError,
style: TextStyle(
fontSize: 12,
color: CupertinoColors.systemRed.resolveFrom(context),
),
),
),
),
const SizedBox(height: 8),
Text(
'Time: ${report.dateTime.toIso8601String()}',
style: TextStyle(
fontSize: 11,
color: CupertinoColors.secondaryLabel.resolveFrom(context),
),
),
],
),
actions: [
CupertinoDialogAction(
onPressed: () => _copyToClipboard(context),
child: const Text('Copy Details'),
),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: onReject,
child: const Text('Dismiss'),
),
CupertinoDialogAction(
isDefaultAction: true,
onPressed: onAccept,
child: const Text('Confirm'),
),
],
);
}
}
class _ConsoleLogHandler extends ReportHandler {
DateTime? _lastLog;
static const _cooldown = Duration(seconds: 3);
@override
Future<bool> handle(Report report, BuildContext? context) async {
final now = DateTime.now();
final ts = report.dateTime.millisecondsSinceEpoch
.toRadixString(36)
.toUpperCase();
final errorId = 'ERR-$ts';
// Flutter 框架内部已知非致命错误:降级为 Log.w不记录到 CrashLogService
if (Catcher2ConfigService._isSilentFrameworkError(report.error)) {
if (_lastLog == null || now.difference(_lastLog!) > _cooldown) {
_lastLog = now;
Log.w('[$errorId] Flutter 框架非致命错误(已静默): ${report.error}');
}
return true;
}
if (_lastLog == null || now.difference(_lastLog!) > _cooldown) {
_lastLog = now;
Log.e('[$errorId] ${report.error}');
if (kDebugMode) {
debugPrint('──────────────────────────────────────');
debugPrint('[$errorId] Catcher2 异常详情:');
debugPrint('Error: ${report.error}');
debugPrint('Stack: ${report.stackTrace}');
debugPrint('──────────────────────────────────────');
}
}
CrashLogService.instance.addLog(
error: report.error.toString(),
stackTrace: report.stackTrace.toString(),
);
return true;
}
@override
List<PlatformType> getSupportedPlatforms() => PlatformType.values.toList();
@override
bool isContextRequired() => false;
}