主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
331 lines
10 KiB
Dart
331 lines
10 KiB
Dart
/// ============================================================
|
||
/// 闲言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 而非 runAppFunction,Catcher2 会在内部调用 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;
|
||
}
|