Files
xianyan/lib/core/services/catcher2_config_service.dart
Developer 83720002e6 feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备
2. 添加跨平台系统托盘支持,新增托盘图标资源
3. 修复工作台模式下导航返回异常问题
4. 统一JSON类型安全解析,替换硬类型转换
5. 增加macOS深度链接支持,统一渠道分发信息
6. 优化部分页面生命周期和状态加载逻辑
7. 移除废弃的nearby_connections依赖
2026-06-19 06:43:55 +08:00

304 lines
9.1 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-06
/// 作用: 统一管理 Catcher2 异常捕获开关与动态配置更新
/// 上次更新: 错误弹窗中文化改英文、复制显示字数上限提升至2000
/// ============================================================
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;
}
/// 初始化 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 {
// 布局溢出错误不弹窗
final errorStr = report.error.toString();
if (errorStr.contains('overflowed') || errorStr.contains('RenderFlex')) {
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';
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;
}