2068 lines
77 KiB
Dart
Executable File
2068 lines
77 KiB
Dart
Executable File
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/services.dart';
|
||
import 'package:fpdart/fpdart.dart';
|
||
import 'package:get/get.dart';
|
||
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
|
||
import 'package:kaer_with_panels/singbox/service/singbox_service.dart';
|
||
import 'package:kaer_with_panels/singbox/service/singbox_service_provider.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:path/path.dart' as p;
|
||
|
||
import '../../../core/model/directories.dart';
|
||
import '../../../singbox/model/singbox_config_option.dart';
|
||
import '../../../singbox/model/singbox_outbound.dart';
|
||
import '../../../singbox/model/singbox_stats.dart';
|
||
import '../../../singbox/model/singbox_status.dart';
|
||
import '../../utils/kr_country_util.dart';
|
||
import '../../utils/kr_log_util.dart';
|
||
import '../../utils/kr_secure_storage.dart';
|
||
import '../../utils/kr_windows_dns_util.dart';
|
||
import '../../common/app_run_data.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
enum KRConnectionType {
|
||
global,
|
||
rule,
|
||
// direct,
|
||
}
|
||
|
||
class KRSingBoxImp {
|
||
/// 私有构造函数
|
||
KRSingBoxImp._();
|
||
|
||
/// 单例实例
|
||
static final KRSingBoxImp _instance = KRSingBoxImp._();
|
||
|
||
/// 工厂构造函数
|
||
factory KRSingBoxImp() => _instance;
|
||
|
||
/// 获取实例的静态方法
|
||
static KRSingBoxImp get instance => _instance;
|
||
|
||
/// 配置文件目录
|
||
late Directories kr_configDics;
|
||
|
||
/// 配置文件名称
|
||
String kr_configName = "hiFastVPN";
|
||
|
||
/// 存储键:用户选择的节点
|
||
static const String _keySelectedNode = 'SELECTED_NODE_TAG';
|
||
|
||
/// 通道方法
|
||
final _kr_methodChannel = const MethodChannel("com.hi.app/platform");
|
||
|
||
final _kr_container = ProviderContainer();
|
||
|
||
/// 核心服务
|
||
late SingboxService kr_singBox;
|
||
|
||
/// more配置
|
||
Map<String, dynamic> kr_configOption = {};
|
||
|
||
List<Map<String, dynamic>> kr_outbounds = [];
|
||
|
||
/// 首次启动
|
||
RxBool kr_isFristStart = false.obs;
|
||
|
||
/// 状态
|
||
final kr_status = SingboxStatus.stopped().obs;
|
||
|
||
/// 拦截广告
|
||
final kr_blockAds = false.obs;
|
||
|
||
/// 是否自动自动选择线路
|
||
final kr_isAutoOutbound = false.obs;
|
||
|
||
/// 连接类型
|
||
final kr_connectionType = KRConnectionType.rule.obs;
|
||
|
||
String _cutPath = "";
|
||
|
||
/// 端口
|
||
int kr_port = 51213;
|
||
|
||
/// 统计
|
||
final kr_stats = SingboxStats(
|
||
connectionsIn: 0,
|
||
connectionsOut: 0,
|
||
uplink: 0,
|
||
downlink: 0,
|
||
uplinkTotal: 0,
|
||
downlinkTotal: 0,
|
||
).obs;
|
||
|
||
/// 活动的出站分组
|
||
RxList<SingboxOutboundGroup> kr_activeGroups = <SingboxOutboundGroup>[].obs;
|
||
|
||
// 🔒 Windows DNS 优化:标志位,记录 DNS 是否已备份
|
||
bool _dnsBackedUp = false;
|
||
|
||
/// 所有的出站分组
|
||
RxList<SingboxOutboundGroup> kr_allGroups = <SingboxOutboundGroup>[].obs;
|
||
|
||
/// Stream 订阅管理器
|
||
final List<StreamSubscription<dynamic>> _kr_subscriptions = [];
|
||
|
||
StreamSubscription<SingboxStatus>? _kr_statusSubscription;
|
||
|
||
/// 初始化标志,防止重复初始化
|
||
bool _kr_isInitialized = false;
|
||
|
||
/// 初始化进行中共享 Future(single-flight 防并发)
|
||
Future<void>? _kr_initFuture;
|
||
|
||
/// 当前混合代理端口是否就绪
|
||
bool get kr_isProxyReady => kr_status.value is SingboxStarted;
|
||
|
||
String? _lastProxyRule;
|
||
|
||
/// 构建 Dart HttpClient 可识别的代理规则字符串
|
||
///
|
||
/// 当 sing-box 尚未启动时返回 `DIRECT`,启动后返回
|
||
/// `PROXY 127.0.0.1:<port>; DIRECT`,以便在代理不可用时自动回落。
|
||
String kr_buildProxyRule({bool includeDirectFallback = true}) {
|
||
if (!kr_isProxyReady) {
|
||
const directRule = 'DIRECT';
|
||
if (_lastProxyRule != directRule) {
|
||
KRLogUtil.kr_i('⏳ sing-box 未就绪,使用 DIRECT 直连', tag: 'SingBox');
|
||
_lastProxyRule = directRule;
|
||
}
|
||
return directRule;
|
||
}
|
||
final proxyRule = StringBuffer('PROXY 127.0.0.1:$kr_port');
|
||
if (includeDirectFallback) {
|
||
proxyRule.write('; DIRECT');
|
||
}
|
||
final ruleString = proxyRule.toString();
|
||
if (_lastProxyRule != ruleString) {
|
||
KRLogUtil.kr_i('🛠️ 使用代理规则: $ruleString', tag: 'SingBox');
|
||
_lastProxyRule = ruleString;
|
||
}
|
||
return ruleString;
|
||
}
|
||
|
||
/// 初始化
|
||
Future<void> init() async {
|
||
// 防止重复初始化(已完成)
|
||
if (_kr_isInitialized) {
|
||
KRLogUtil.kr_i('SingBox 已经初始化,跳过重复初始化', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
// 防止并发重复初始化(进行中复用同一个 Future)
|
||
if (_kr_initFuture != null) {
|
||
KRLogUtil.kr_i('SingBox 初始化进行中,等待完成(single-flight)', tag: 'SingBox');
|
||
await _kr_initFuture;
|
||
return;
|
||
}
|
||
|
||
// 建立 single-flight 共享 Future
|
||
final completer = Completer<void>();
|
||
_kr_initFuture = completer.future;
|
||
|
||
try {
|
||
KRLogUtil.kr_i('开始初始化 SingBox');
|
||
// 在应用启动时初始化
|
||
await KRCountryUtil.kr_init();
|
||
KRLogUtil.kr_i('国家工具初始化完成');
|
||
|
||
final oOption = SingboxConfigOption.fromJson(_getConfigOption());
|
||
KRLogUtil.kr_i('配置选项初始化完成');
|
||
|
||
KRLogUtil.kr_i('开始初始化 SingBox 服务');
|
||
kr_singBox = await _kr_container.read(singboxServiceProvider);
|
||
await _kr_container.read(singboxServiceProvider).init();
|
||
KRLogUtil.kr_i('SingBox 服务初始化完成');
|
||
|
||
KRLogUtil.kr_i('开始初始化目录');
|
||
|
||
/// 初始化目录
|
||
if (Platform.isIOS) {
|
||
final paths = await _kr_methodChannel.invokeMethod<Map>("get_paths");
|
||
KRLogUtil.kr_i('iOS 路径获取完成: $paths');
|
||
|
||
kr_configDics = (
|
||
baseDir: Directory(paths?["base"]! as String),
|
||
workingDir: Directory(paths?["working"]! as String),
|
||
tempDir: Directory(paths?["temp"]! as String),
|
||
);
|
||
} else {
|
||
final baseDir = await getApplicationSupportDirectory();
|
||
final workingDir =
|
||
Platform.isAndroid ? await getExternalStorageDirectory() : baseDir;
|
||
final tempDir = await getTemporaryDirectory();
|
||
|
||
// Windows 路径规范化:确保使用正确的路径分隔符
|
||
Directory normalizePath(Directory dir) {
|
||
if (Platform.isWindows) {
|
||
final normalized = dir.path.replaceAll('/', '\\');
|
||
if (normalized != dir.path) {
|
||
KRLogUtil.kr_i('路径规范化: ${dir.path} -> $normalized',
|
||
tag: 'SingBox');
|
||
return Directory(normalized);
|
||
}
|
||
}
|
||
return dir;
|
||
}
|
||
|
||
kr_configDics = (
|
||
baseDir: normalizePath(baseDir),
|
||
workingDir: normalizePath(workingDir!),
|
||
tempDir: normalizePath(tempDir),
|
||
);
|
||
KRLogUtil.kr_i('其他平台路径初始化完成');
|
||
}
|
||
|
||
KRLogUtil.kr_i('开始创建目录');
|
||
KRLogUtil.kr_i('baseDir: ${kr_configDics.baseDir.path}', tag: 'SingBox');
|
||
KRLogUtil.kr_i('workingDir: ${kr_configDics.workingDir.path}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i('tempDir: ${kr_configDics.tempDir.path}', tag: 'SingBox');
|
||
|
||
// 确保所有目录都存在
|
||
if (!kr_configDics.baseDir.existsSync()) {
|
||
await kr_configDics.baseDir.create(recursive: true);
|
||
KRLogUtil.kr_i('已创建 baseDir', tag: 'SingBox');
|
||
}
|
||
if (!kr_configDics.workingDir.existsSync()) {
|
||
await kr_configDics.workingDir.create(recursive: true);
|
||
KRLogUtil.kr_i('已创建 workingDir', tag: 'SingBox');
|
||
}
|
||
if (!kr_configDics.tempDir.existsSync()) {
|
||
await kr_configDics.tempDir.create(recursive: true);
|
||
KRLogUtil.kr_i('已创建 tempDir', tag: 'SingBox');
|
||
}
|
||
|
||
// 创建 libcore 数据库所需的 data 目录(在 workingDir 下)
|
||
// 注意:libcore 的 Setup 会调用 os.Chdir(workingPath),所以 data 目录必须在 workingDir 下
|
||
final dataDir = Directory(p.join(kr_configDics.workingDir.path, 'data'));
|
||
|
||
// 强制确保 data 目录存在(Windows 可能需要多次尝试)
|
||
int retryCount = 0;
|
||
const maxRetries = 5;
|
||
while (!dataDir.existsSync() && retryCount < maxRetries) {
|
||
try {
|
||
await dataDir.create(recursive: true);
|
||
// 等待文件系统同步(Windows 上可能需要一点时间)
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// 验证目录确实创建成功
|
||
if (dataDir.existsSync()) {
|
||
KRLogUtil.kr_i('✅ 已创建 data 目录: ${dataDir.path}', tag: 'SingBox');
|
||
break;
|
||
} else {
|
||
retryCount++;
|
||
KRLogUtil.kr_i('⚠️ data 目录创建后验证失败,重试 $retryCount/$maxRetries',
|
||
tag: 'SingBox');
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
} catch (e) {
|
||
retryCount++;
|
||
KRLogUtil.kr_e('❌ 创建 data 目录失败 (尝试 $retryCount/$maxRetries): $e',
|
||
tag: 'SingBox');
|
||
if (retryCount >= maxRetries) {
|
||
throw Exception('无法创建 libcore 数据库目录: ${dataDir.path},错误: $e');
|
||
}
|
||
final delayMs = 200 * retryCount;
|
||
await Future.delayed(Duration(milliseconds: delayMs));
|
||
}
|
||
}
|
||
|
||
if (!dataDir.existsSync()) {
|
||
final error = 'data 目录不存在: ${dataDir.path}';
|
||
KRLogUtil.kr_e('❌ $error', tag: 'SingBox');
|
||
throw Exception(error);
|
||
}
|
||
|
||
// 验证目录权限(尝试创建一个测试文件)
|
||
try {
|
||
final testFile = File(p.join(dataDir.path, '.test_write'));
|
||
await testFile.writeAsString('test');
|
||
await testFile.delete();
|
||
KRLogUtil.kr_i('✅ data 目录写入权限验证通过', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('⚠️ data 目录写入权限验证失败: $e', tag: 'SingBox');
|
||
// 不抛出异常,让 libcore 自己处理
|
||
}
|
||
|
||
// 在 Windows 上额外等待,确保文件系统操作完成
|
||
if (Platform.isWindows) {
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
KRLogUtil.kr_i('⏳ Windows 文件系统同步等待完成', tag: 'SingBox');
|
||
}
|
||
|
||
// 最终验证:在 setup() 之前再次确认 workingDir 和 data 目录都存在且可访问
|
||
// libcore 的 Setup() 会调用 os.Chdir(workingPath),然后使用相对路径 "./data"
|
||
// 如果 os.Chdir() 失败(路径不存在或权限问题),后续的相对路径访问会失败
|
||
if (!kr_configDics.workingDir.existsSync()) {
|
||
final error =
|
||
'❌ workingDir 不存在,无法调用 setup(): ${kr_configDics.workingDir.path}';
|
||
KRLogUtil.kr_e(error, tag: 'SingBox');
|
||
throw Exception(error);
|
||
}
|
||
|
||
// 验证 workingDir 可读可写
|
||
try {
|
||
final testWorkingFile =
|
||
File(p.join(kr_configDics.workingDir.path, '.test_working_dir'));
|
||
await testWorkingFile.writeAsString('test');
|
||
await testWorkingFile.delete();
|
||
KRLogUtil.kr_i('✅ workingDir 写入权限验证通过', tag: 'SingBox');
|
||
} catch (e) {
|
||
final error =
|
||
'❌ workingDir 无写入权限: ${kr_configDics.workingDir.path}, 错误: $e';
|
||
KRLogUtil.kr_e(error, tag: 'SingBox');
|
||
throw Exception(error);
|
||
}
|
||
|
||
final finalDataDir =
|
||
Directory(p.join(kr_configDics.workingDir.path, 'data'));
|
||
if (!finalDataDir.existsSync()) {
|
||
KRLogUtil.kr_e('❌ 最终验证失败:data 目录不存在', tag: 'SingBox');
|
||
KRLogUtil.kr_e('路径: ${finalDataDir.path}', tag: 'SingBox');
|
||
KRLogUtil.kr_e(
|
||
'workingDir 是否存在: ${kr_configDics.workingDir.existsSync()}',
|
||
tag: 'SingBox');
|
||
if (kr_configDics.workingDir.existsSync()) {
|
||
try {
|
||
final workingDirContents = kr_configDics.workingDir.listSync();
|
||
KRLogUtil.kr_e(
|
||
'workingDir 内容: ${workingDirContents.map((e) => e.path.split(Platform.pathSeparator).last).join(", ")}',
|
||
tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('无法列出 workingDir 内容: $e', tag: 'SingBox');
|
||
}
|
||
}
|
||
throw Exception('data 目录在 setup() 前验证失败: ${finalDataDir.path}');
|
||
}
|
||
|
||
// 再次尝试写入测试,确保目录确实可用
|
||
try {
|
||
final verifyFile = File(p.join(finalDataDir.path, '.verify_setup'));
|
||
await verifyFile.writeAsString('verify');
|
||
await verifyFile.delete();
|
||
KRLogUtil.kr_i('✅ setup() 前最终验证通过', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ setup() 前最终验证失败: $e', tag: 'SingBox');
|
||
// 不抛出异常,让 setup() 自己处理
|
||
}
|
||
|
||
final configsDir =
|
||
Directory(p.join(kr_configDics.workingDir.path, "configs"));
|
||
if (!configsDir.existsSync()) {
|
||
try {
|
||
await configsDir.create(recursive: true);
|
||
KRLogUtil.kr_i('✅ 已创建 configs 目录: ${configsDir.path}',
|
||
tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('⚠️ configs 目录创建失败: $e', tag: 'SingBox');
|
||
// 不抛出异常,继续初始化
|
||
}
|
||
} else {
|
||
KRLogUtil.kr_i('✅ configs 目录已存在: ${configsDir.path}', tag: 'SingBox');
|
||
}
|
||
|
||
// 特别处理 extensionData.db 文件 (Windows特定)
|
||
if (Platform.isWindows) {
|
||
try {
|
||
final extensionDataDbPath =
|
||
p.join(finalDataDir.path, 'extensionData.db');
|
||
KRLogUtil.kr_i('👉 准备处理 extensionData.db 路径: $extensionDataDbPath',
|
||
tag: 'SingBox');
|
||
|
||
// 确保 extensionData.db 的父目录存在
|
||
final extensionDataParent = Directory(p.dirname(extensionDataDbPath));
|
||
if (!extensionDataParent.existsSync()) {
|
||
await extensionDataParent.create(recursive: true);
|
||
KRLogUtil.kr_i('✅ 已创建 extensionData.db 父目录', tag: 'SingBox');
|
||
}
|
||
|
||
// 测试文件创建权限
|
||
final testFile =
|
||
File(p.join(extensionDataParent.path, '.test_extension'));
|
||
await testFile.writeAsString('test');
|
||
await testFile.delete();
|
||
KRLogUtil.kr_i('✅ extensionData 目录权限验证通过', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('⚠️ extensionData 目录处理失败: $e', tag: 'SingBox');
|
||
// 不抛出异常,继续初始化
|
||
}
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ 目录创建完成', tag: 'SingBox');
|
||
KRLogUtil.kr_i('开始设置 SingBox', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - baseDir: ${kr_configDics.baseDir.path}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - workingDir: ${kr_configDics.workingDir.path}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - tempDir: ${kr_configDics.tempDir.path}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i(
|
||
' - data 目录: ${p.join(kr_configDics.workingDir.path, "data")}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - data 目录存在: ${finalDataDir.existsSync()}',
|
||
tag: 'SingBox');
|
||
|
||
// 在 Windows 上,列出 data 目录内容(如果有文件)
|
||
if (Platform.isWindows && finalDataDir.existsSync()) {
|
||
try {
|
||
final dataContents = finalDataDir.listSync();
|
||
if (dataContents.isNotEmpty) {
|
||
KRLogUtil.kr_i(
|
||
' - data 目录现有文件: ${dataContents.map((e) => e.path.split(Platform.pathSeparator).last).join(", ")}',
|
||
tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_i(' - data 目录为空', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e(' - 无法列出 data 目录内容: $e', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
KRLogUtil.kr_i(
|
||
' - libcore 将通过 os.Chdir() 切换到: ${kr_configDics.workingDir.path}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - 然后使用相对路径 "./data" 访问数据库', tag: 'SingBox');
|
||
|
||
// Windows 特定:验证路径格式是否正确
|
||
if (Platform.isWindows) {
|
||
final workingPath = kr_configDics.workingDir.path;
|
||
if (workingPath.contains('/')) {
|
||
KRLogUtil.kr_e('⚠️ 警告:Windows 路径包含正斜杠,可能导致问题: $workingPath',
|
||
tag: 'SingBox');
|
||
}
|
||
// 确保路径使用反斜杠(Windows 标准)
|
||
final normalizedPath = workingPath.replaceAll('/', '\\');
|
||
if (normalizedPath != workingPath) {
|
||
KRLogUtil.kr_e('⚠️ 路径格式可能需要规范化: $workingPath -> $normalizedPath',
|
||
tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
// 🔑 关键步骤:调用 setup() 将 NativePort 传递给 libcore
|
||
// 这样 libcore 才能通过 GoDart_PostCObject() 向 Dart 发送消息
|
||
KRLogUtil.kr_i('📡 开始调用 setup() 注册 FFI 端口', tag: 'SingBox');
|
||
final setupResult = await kr_singBox.setup(kr_configDics, false).run();
|
||
setupResult.match(
|
||
(error) {
|
||
KRLogUtil.kr_e('❌ setup() 失败: $error', tag: 'SingBox');
|
||
throw Exception('FFI setup 失败: $error');
|
||
},
|
||
(_) {
|
||
KRLogUtil.kr_i('✅ setup() 成功,FFI 端口已注册', tag: 'SingBox');
|
||
},
|
||
);
|
||
|
||
KRLogUtil.kr_i('✅ SingBox 初始化完成');
|
||
|
||
// 🔧 提取 geosite 文件到 workingDir
|
||
KRLogUtil.kr_i('📦 开始提取 geosite 文件...', tag: 'SingBox');
|
||
await _kr_extractGeositeFiles();
|
||
KRLogUtil.kr_i('✅ geosite 文件提取完成', tag: 'SingBox');
|
||
|
||
_kr_isInitialized = true;
|
||
|
||
// 🔑 关键:在初始化完成后立即订阅状态变化流
|
||
// 这样可以确保 UI 始终与 libcore 的实际状态同步
|
||
_kr_subscribeToStatus();
|
||
KRLogUtil.kr_i('✅ 状态订阅已设置', tag: 'SingBox');
|
||
|
||
// 完成 single-flight
|
||
completer.complete();
|
||
_kr_initFuture = null;
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('❌ SingBox 初始化失败: $e');
|
||
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace');
|
||
|
||
// 添加额外的诊断信息
|
||
if (Platform.isWindows) {
|
||
try {
|
||
final workingDir = kr_configDics.workingDir;
|
||
final dataDir = Directory(p.join(workingDir.path, 'data'));
|
||
final configsDir = Directory(p.join(workingDir.path, 'configs'));
|
||
|
||
KRLogUtil.kr_e('🔍 Windows 路径诊断信息:', tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - workingDir: ${workingDir.path}', tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - workingDir 存在: ${workingDir.existsSync()}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - data 目录: ${dataDir.path}', tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - data 目录存在: ${dataDir.existsSync()}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - configs 目录: ${configsDir.path}', tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - configs 目录存在: ${configsDir.existsSync()}',
|
||
tag: 'SingBox');
|
||
|
||
// 检查父目录内容
|
||
if (workingDir.existsSync()) {
|
||
try {
|
||
final contents = workingDir.listSync();
|
||
KRLogUtil.kr_e(
|
||
' - workingDir 内容: ${contents.map((e) => "${e.path.split(Platform.pathSeparator).last}${e is Directory ? "/" : ""}").join(", ")}',
|
||
tag: 'SingBox');
|
||
} catch (listErr) {
|
||
KRLogUtil.kr_e(' - 无法列出 workingDir 内容: $listErr',
|
||
tag: 'SingBox');
|
||
}
|
||
}
|
||
} catch (diagErr) {
|
||
KRLogUtil.kr_e(' - 诊断信息收集失败: $diagErr', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
// 如果初始化失败,允许下次重试
|
||
_kr_isInitialized = false;
|
||
// 失败时通知等待者并清理 single-flight
|
||
if (!completer.isCompleted) {
|
||
completer.completeError(e, stackTrace);
|
||
}
|
||
_kr_initFuture = null;
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 提取 geosite 文件到 workingDir
|
||
Future<void> _kr_extractGeositeFiles() async {
|
||
try {
|
||
// 创建 geosite 目录
|
||
final geositeDir =
|
||
Directory(p.join(kr_configDics.workingDir.path, 'geosite'));
|
||
if (!geositeDir.existsSync()) {
|
||
await geositeDir.create(recursive: true);
|
||
KRLogUtil.kr_i('✅ 已创建 geosite 目录: ${geositeDir.path}', tag: 'SingBox');
|
||
}
|
||
|
||
// 需要提取的文件列表
|
||
final files = ['geoip-cn.srs', 'geosite-cn.srs'];
|
||
|
||
for (final filename in files) {
|
||
final assetPath = 'assets/geosite/$filename';
|
||
final targetPath = p.join(geositeDir.path, filename);
|
||
final targetFile = File(targetPath);
|
||
|
||
// 检查文件是否已存在
|
||
if (targetFile.existsSync()) {
|
||
final fileSize = await targetFile.length();
|
||
KRLogUtil.kr_i('📄 $filename 已存在 (${fileSize} bytes), 跳过',
|
||
tag: 'SingBox');
|
||
continue;
|
||
}
|
||
|
||
// 从 assets 加载文件
|
||
KRLogUtil.kr_i('📥 正在提取 $filename...', tag: 'SingBox');
|
||
final byteData = await rootBundle.load(assetPath);
|
||
final bytes = byteData.buffer.asUint8List();
|
||
|
||
// 写入文件系统
|
||
await targetFile.writeAsBytes(bytes);
|
||
final writtenSize = await targetFile.length();
|
||
|
||
KRLogUtil.kr_i('✅ 提取成功: $filename (${writtenSize} bytes)',
|
||
tag: 'SingBox');
|
||
}
|
||
|
||
KRLogUtil.kr_i('🎉 所有 geosite 文件提取完成', tag: 'SingBox');
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('❌ 提取 geosite 文件失败: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_e('堆栈: $stackTrace', tag: 'SingBox');
|
||
// 不抛出异常,让应用继续运行(使用远程规则集作为后备)
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> _getConfigOption() {
|
||
// 不使用缓存,每次都重新生成配置
|
||
// if (kr_configOption.isNotEmpty) {
|
||
// return kr_configOption;
|
||
// }
|
||
|
||
// 🔧 关键修复:全局代理模式下,region 强制设为 'other',libcore 就不会生成国家直连规则
|
||
final String effectiveRegion;
|
||
if (kr_connectionType.value == KRConnectionType.global) {
|
||
effectiveRegion = 'other'; // 全局代理:不添加任何国家规则
|
||
KRLogUtil.kr_i('🌐 [全局代理模式] region 设为 other,所有流量走代理', tag: 'SingBox');
|
||
} else {
|
||
effectiveRegion =
|
||
KRCountryUtil.kr_getCurrentCountryCode(); // 智能代理:使用用户选择的国家
|
||
KRLogUtil.kr_i('✅ [智能代理模式] region 设为 $effectiveRegion', tag: 'SingBox');
|
||
}
|
||
|
||
final op = {
|
||
"region": effectiveRegion, // 🔧 修复:根据出站模式动态设置 region
|
||
"block-ads": false, // 参考 hiddify-app: 默认关闭广告拦截
|
||
"use-xray-core-when-possible": false,
|
||
"execute-config-as-is": true, // 🔧 修复:使用我们生成的完整配置,不让 libcore 重写
|
||
"log-level": "info", // 调试阶段使用 info,生产环境改为 warn
|
||
"resolve-destination": false,
|
||
"ipv6-mode":
|
||
"ipv4_only", // 参考 hiddify-app: 仅使用 IPv4 (有效值: ipv4_only, prefer_ipv4, prefer_ipv6, ipv6_only)
|
||
"remote-dns-address":
|
||
"https://dns.google/dns-query", // 使用 Google DoH,避免中转节点 DNS 死锁
|
||
"remote-dns-domain-strategy": "prefer_ipv4",
|
||
"direct-dns-address": "local", // 使用系统 DNS,确保中转服务器域名能被解析
|
||
"direct-dns-domain-strategy": "prefer_ipv4",
|
||
"mixed-port": kr_port,
|
||
"tproxy-port": kr_port,
|
||
"local-dns-port": 36450,
|
||
"tun-implementation": "gvisor",
|
||
"mtu": 9000,
|
||
"strict-route": true,
|
||
"connection-test-url":
|
||
"http://cp.cloudflare.com", // 参考 hiddify-app: 使用 Cloudflare 测试端点
|
||
"url-test-interval": 30,
|
||
"enable-clash-api": true,
|
||
"clash-api-port": 36756,
|
||
"enable-tun": Platform.isIOS || Platform.isAndroid,
|
||
"enable-tun-service": false,
|
||
"set-system-proxy":
|
||
Platform.isWindows || Platform.isLinux || Platform.isMacOS,
|
||
"bypass-lan": false,
|
||
"allow-connection-from-lan": false,
|
||
"enable-fake-dns": false,
|
||
"enable-dns-routing": true,
|
||
"independent-dns-cache": true,
|
||
"rules": _kr_buildHiddifyRules(),
|
||
"mux": {
|
||
"enable": false,
|
||
"padding": false,
|
||
"max-streams": 8,
|
||
"protocol": "h2mux"
|
||
},
|
||
"tls-tricks": {
|
||
"enable-fragment": false,
|
||
"fragment-size": "10-30",
|
||
"fragment-sleep": "2-8",
|
||
"mixed-sni-case": false,
|
||
"enable-padding": false,
|
||
"padding-size": "1-1500"
|
||
},
|
||
"warp": {
|
||
"enable": false,
|
||
"mode": "proxy_over_warp",
|
||
"wireguard-config": "",
|
||
"license-key": "",
|
||
"account-id": "",
|
||
"access-token": "",
|
||
"clean-ip": "auto",
|
||
"clean-port": 0,
|
||
"noise": "1-3",
|
||
"noise-size": "10-30",
|
||
"noise-delay": "10-30",
|
||
"noise-mode": "m4"
|
||
},
|
||
"warp2": {
|
||
"enable": false,
|
||
"mode": "proxy_over_warp",
|
||
"wireguard-config": "",
|
||
"license-key": "",
|
||
"account-id": "",
|
||
"access-token": "",
|
||
"clean-ip": "auto",
|
||
"clean-port": 0,
|
||
"noise": "1-3",
|
||
"noise-size": "10-30",
|
||
"noise-delay": "10-30",
|
||
"noise-mode": "m4"
|
||
}
|
||
};
|
||
kr_configOption = op;
|
||
|
||
// 🔧 调试日志:确认自定义规则已添加
|
||
final rules = op["rules"] as List?;
|
||
KRLogUtil.kr_i('✅ HiddifyOptions 已生成,包含 ${rules?.length ?? 0} 条自定义路由规则',
|
||
tag: 'SingBox');
|
||
if (rules != null && rules.isNotEmpty) {
|
||
for (var rule in rules) {
|
||
final ruleMap = rule as Map<String, dynamic>;
|
||
KRLogUtil.kr_i(
|
||
' - 规则: domains=${ruleMap["domains"]}, outbound=${ruleMap["outbound"]}',
|
||
tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
return op;
|
||
}
|
||
|
||
List<Map<String, dynamic>> _kr_buildHiddifyRules() {
|
||
final rules = <Map<String, dynamic>>[];
|
||
rules.add({"domains": "domain:api.hifast.biz", "outbound": "bypass"});
|
||
|
||
final nodeDomains = _kr_collectNodeDomains();
|
||
for (final d in nodeDomains) {
|
||
rules.add({"domains": "domain:$d", "outbound": "bypass"});
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ 节点域名白名单数量: ${nodeDomains.length}', tag: 'SingBox');
|
||
KRLogUtil.kr_i('✅ 节点域名白名单集合: ${jsonEncode(nodeDomains.toList())}',
|
||
tag: 'SingBox');
|
||
return rules;
|
||
}
|
||
|
||
Set<String> _kr_collectNodeDomains() {
|
||
final set = <String>{};
|
||
|
||
void addFromOutbound(Map<String, dynamic> o) {
|
||
final server = o['server']?.toString();
|
||
if (server != null &&
|
||
server.isNotEmpty &&
|
||
InternetAddress.tryParse(server) == null) {
|
||
set.add(server.toLowerCase());
|
||
}
|
||
|
||
final tls = o['tls'];
|
||
if (tls is Map) {
|
||
final sni = tls['server_name']?.toString();
|
||
if (sni != null &&
|
||
sni.isNotEmpty &&
|
||
InternetAddress.tryParse(sni) == null) {
|
||
set.add(sni.toLowerCase());
|
||
}
|
||
}
|
||
}
|
||
|
||
for (final g in kr_outbounds) {
|
||
addFromOutbound(g);
|
||
}
|
||
|
||
return set;
|
||
}
|
||
|
||
/// 订阅状态变化流
|
||
/// 参考 hiddify-app: 监听 libcore 发送的状态事件来自动更新 UI
|
||
void _kr_subscribeToStatus() {
|
||
if (kDebugMode) {
|
||
print('🔵 _kr_subscribeToStatus 被调用,重新订阅状态流');
|
||
}
|
||
KRLogUtil.kr_i('🔵 _kr_subscribeToStatus 被调用', tag: 'SingBox');
|
||
|
||
_kr_statusSubscription?.cancel();
|
||
_kr_statusSubscription = kr_singBox.watchStatus().listen(
|
||
(status) {
|
||
if (status == kr_status.value) {
|
||
return;
|
||
}
|
||
if (kDebugMode) {
|
||
print('🔵 收到 Native 状态更新: ${status.runtimeType}');
|
||
}
|
||
KRLogUtil.kr_i('📡 收到状态更新: $status', tag: 'SingBox');
|
||
kr_status.value = status;
|
||
},
|
||
onError: (error) {
|
||
if (kDebugMode) {
|
||
print('🔵 状态流错误: $error');
|
||
}
|
||
KRLogUtil.kr_e('📡 状态流错误: $error', tag: 'SingBox');
|
||
},
|
||
cancelOnError: false,
|
||
);
|
||
|
||
if (kDebugMode) {
|
||
print('🔵 状态流订阅完成');
|
||
}
|
||
}
|
||
|
||
/// 订阅统计数据流
|
||
void _kr_subscribeToStats() {
|
||
// 取消之前的统计订阅
|
||
for (var sub in _kr_subscriptions) {
|
||
if (sub.hashCode.toString().contains('Stats')) {
|
||
sub.cancel();
|
||
}
|
||
}
|
||
_kr_subscriptions
|
||
.removeWhere((sub) => sub.hashCode.toString().contains('Stats'));
|
||
|
||
// ⚠️ 关键:watchStats() 内部会调用 FFI startCommandClient
|
||
// 如果此时 command.sock 未就绪,会抛出异常
|
||
// 所以外层必须有 try-catch
|
||
final stream = kr_singBox.watchStats();
|
||
final subscription = stream.listen(
|
||
(stats) {
|
||
kr_stats.value = stats;
|
||
},
|
||
onError: (error) {
|
||
KRLogUtil.kr_e('统计数据监听错误: $error', tag: 'SingBox');
|
||
},
|
||
cancelOnError: false,
|
||
);
|
||
_kr_subscriptions.add(subscription);
|
||
}
|
||
|
||
/// 订阅分组数据流
|
||
void _kr_subscribeToGroups() {
|
||
print('[_kr_subscribeToGroups] 🚀 开始订阅分组数据流');
|
||
// 取消之前的分组订阅
|
||
for (var sub in _kr_subscriptions) {
|
||
if (sub.hashCode.toString().contains('Groups')) {
|
||
sub.cancel();
|
||
}
|
||
}
|
||
_kr_subscriptions
|
||
.removeWhere((sub) => sub.hashCode.toString().contains('Groups'));
|
||
|
||
_kr_subscriptions.add(
|
||
kr_singBox.watchActiveGroups().listen(
|
||
(groups) {
|
||
print('[watchActiveGroups] 📡 收到活动组更新,数量: ${groups.length}');
|
||
KRLogUtil.kr_i('📡 收到活动组更新,数量: ${groups.length}', tag: 'SingBox');
|
||
kr_activeGroups.value = groups;
|
||
|
||
// 详细打印每个组的信息
|
||
for (int i = 0; i < groups.length; i++) {
|
||
final group = groups[i];
|
||
KRLogUtil.kr_i(
|
||
'📋 活动组[$i]: tag=${group.tag}, type=${group.type}, selected=${group.selected}',
|
||
tag: 'SingBox');
|
||
for (int j = 0; j < group.items.length; j++) {
|
||
final item = group.items[j];
|
||
KRLogUtil.kr_i(
|
||
' └─ 节点[$j]: tag=${item.tag}, type=${item.type}, delay=${item.urlTestDelay}',
|
||
tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ 活动组处理完成', tag: 'SingBox');
|
||
},
|
||
onError: (error) {
|
||
print('[watchActiveGroups] ❌ 活动分组监听错误: $error');
|
||
KRLogUtil.kr_e('❌ 活动分组监听错误: $error', tag: 'SingBox');
|
||
},
|
||
cancelOnError: false,
|
||
),
|
||
);
|
||
|
||
_kr_subscriptions.add(
|
||
kr_singBox.watchGroups().listen(
|
||
(groups) {
|
||
print('[watchGroups] 📡 收到所有组更新,数量: ${groups.length}');
|
||
kr_allGroups.value = groups;
|
||
// 打印每个组的基本信息
|
||
for (int i = 0; i < groups.length; i++) {
|
||
final group = groups[i];
|
||
print(
|
||
'[watchGroups] 组[$i]: tag=${group.tag}, type=${group.type}, selected=${group.selected}');
|
||
}
|
||
},
|
||
onError: (error) {
|
||
print('[watchGroups] ❌ 所有分组监听错误: $error');
|
||
KRLogUtil.kr_e('所有分组监听错误: $error');
|
||
},
|
||
cancelOnError: false,
|
||
),
|
||
);
|
||
print(
|
||
'[_kr_subscribeToGroups] ✅ 分组数据流订阅完成,当前订阅数: ${_kr_subscriptions.length}');
|
||
}
|
||
|
||
/// 验证节点选择是否生效
|
||
///
|
||
/// 检查活动组中 "select" 组的 selected 字段是否是目标节点
|
||
Future<void> _kr_verifyNodeSelection(String targetTag) async {
|
||
try {
|
||
KRLogUtil.kr_i('🔍 开始验证节点选择: $targetTag', tag: 'SingBox');
|
||
|
||
// 查找 "select" 组
|
||
final selectGroup = kr_activeGroups.firstWhere(
|
||
(group) => group.tag == 'select',
|
||
orElse: () => throw Exception('未找到 "select" 选择器组'),
|
||
);
|
||
|
||
KRLogUtil.kr_i('📊 Select 组状态:', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - 组标签: ${selectGroup.tag}', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - 组类型: ${selectGroup.type}', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - 当前选中: ${selectGroup.selected}', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - 目标节点: $targetTag', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - 可用节点数: ${selectGroup.items.length}', tag: 'SingBox');
|
||
|
||
// 验证目标节点是否在可用列表中
|
||
final hasTarget = selectGroup.items.any((item) => item.tag == targetTag);
|
||
if (!hasTarget) {
|
||
KRLogUtil.kr_w('⚠️ 目标节点不在 select 组的可用列表中: $targetTag', tag: 'SingBox');
|
||
KRLogUtil.kr_w('可用节点列表:', tag: 'SingBox');
|
||
for (var item in selectGroup.items) {
|
||
KRLogUtil.kr_w(' - ${item.tag}', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
// 检查是否切换成功
|
||
if (selectGroup.selected != targetTag) {
|
||
KRLogUtil.kr_e('❌ 节点切换验证失败!', tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - 期望: $targetTag', tag: 'SingBox');
|
||
KRLogUtil.kr_e(' - 实际: ${selectGroup.selected}', tag: 'SingBox');
|
||
throw Exception('节点切换失败:实际选中 ${selectGroup.selected},期望 $targetTag');
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ 节点切换验证成功: ${selectGroup.selected} == $targetTag',
|
||
tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 节点验证异常: $e', tag: 'SingBox');
|
||
// 不抛出异常,只记录日志,避免阻塞流程
|
||
}
|
||
}
|
||
|
||
/// 带重试机制的节点选择
|
||
///
|
||
/// 确保 command.sock 准备好后再执行节点选择
|
||
Future<void> _kr_selectOutboundWithRetry(
|
||
String groupTag,
|
||
String outboundTag, {
|
||
int maxAttempts = 3,
|
||
int initialDelay = 100,
|
||
}) async {
|
||
int attempt = 0;
|
||
int delay = initialDelay;
|
||
|
||
while (attempt < maxAttempts) {
|
||
attempt++;
|
||
KRLogUtil.kr_i(
|
||
'🔄 尝试选择节点 $outboundTag (第 $attempt/$maxAttempts 次)',
|
||
tag: 'SingBox',
|
||
);
|
||
|
||
// 只在失败后才延迟,首次尝试立即执行
|
||
if (attempt > 1) {
|
||
await Future.delayed(Duration(milliseconds: delay));
|
||
}
|
||
|
||
try {
|
||
await kr_singBox.selectOutbound(groupTag, outboundTag).run();
|
||
KRLogUtil.kr_i('✅ 节点选择成功: $outboundTag', tag: 'SingBox');
|
||
return;
|
||
} catch (e) {
|
||
KRLogUtil.kr_w(
|
||
'⚠️ 第 $attempt 次节点选择失败: $e',
|
||
tag: 'SingBox',
|
||
);
|
||
|
||
if (attempt >= maxAttempts) {
|
||
KRLogUtil.kr_e(
|
||
'❌ 节点选择失败,已达到最大重试次数',
|
||
tag: 'SingBox',
|
||
);
|
||
throw Exception('节点选择失败: $e');
|
||
}
|
||
|
||
// 指数退避
|
||
delay = delay * 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 带重试机制的命令客户端初始化
|
||
///
|
||
/// command.sock 需要时间创建,因此需要重试机制
|
||
/// maxAttempts: 最大重试次数(默认10次,针对macOS优化)
|
||
/// initialDelay: 初始延迟毫秒数(默认1000ms,针对macOS优化)
|
||
Future<void> _kr_initializeCommandClientsWithRetry({
|
||
int maxAttempts = 10,
|
||
int initialDelay = 1000,
|
||
}) async {
|
||
int attempt = 0;
|
||
int delay = initialDelay;
|
||
|
||
while (attempt < maxAttempts) {
|
||
attempt++;
|
||
KRLogUtil.kr_i(
|
||
'🔄 尝试初始化命令客户端 (第 $attempt/$maxAttempts 次,延迟 ${delay}ms)',
|
||
tag: 'SingBox',
|
||
);
|
||
|
||
await Future.delayed(Duration(milliseconds: delay));
|
||
|
||
try {
|
||
// 先验证 command.sock 是否可访问
|
||
final socketFile =
|
||
File(p.join(kr_configDics.baseDir.path, 'command.sock'));
|
||
KRLogUtil.kr_i('🔍 检查 socket 文件: ${socketFile.path}', tag: 'SingBox');
|
||
|
||
if (!socketFile.existsSync()) {
|
||
throw Exception('command.sock 文件不存在: ${socketFile.path}');
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ socket 文件存在,开始订阅...', tag: 'SingBox');
|
||
|
||
// 尝试订阅统计和分组数据
|
||
// ⚠️ 关键:分别 try-catch,避免一个失败影响另一个
|
||
bool statsSubscribed = false;
|
||
bool groupsSubscribed = false;
|
||
|
||
// ⚠️ 关键修复:使用 Future.delayed(Duration.zero) 将订阅推到下一个事件循环
|
||
// 这样可以避免阻塞当前的异步执行
|
||
try {
|
||
KRLogUtil.kr_i('📊 订阅统计数据流...', tag: 'SingBox');
|
||
await Future.delayed(Duration.zero); // 让出 UI 线程
|
||
_kr_subscribeToStats();
|
||
statsSubscribed = true;
|
||
KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 统计数据流订阅失败: $e', tag: 'SingBox');
|
||
}
|
||
|
||
try {
|
||
KRLogUtil.kr_i('📋 订阅分组数据流...', tag: 'SingBox');
|
||
await Future.delayed(Duration.zero); // 让出 UI 线程
|
||
_kr_subscribeToGroups();
|
||
groupsSubscribed = true;
|
||
KRLogUtil.kr_i('✅ 分组数据流订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 分组数据流订阅失败: $e', tag: 'SingBox');
|
||
}
|
||
|
||
// 至少有一个订阅成功才算初始化成功
|
||
if (!statsSubscribed && !groupsSubscribed) {
|
||
throw Exception('所有订阅都失败了');
|
||
}
|
||
|
||
// 等待更长时间验证订阅是否成功,并实际接收到数据
|
||
KRLogUtil.kr_i('⏳ 等待800ms验证订阅状态...', tag: 'SingBox');
|
||
await Future.delayed(const Duration(milliseconds: 800));
|
||
|
||
// 验证订阅是否真正工作(检查是否有数据流)
|
||
if (_kr_subscriptions.isEmpty) {
|
||
throw Exception('订阅列表为空,command client 未成功连接');
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ 命令客户端初始化成功,活跃订阅数: ${_kr_subscriptions.length}',
|
||
tag: 'SingBox');
|
||
return;
|
||
} catch (e, stackTrace) {
|
||
// 详细记录失败原因和堆栈信息
|
||
KRLogUtil.kr_w(
|
||
'⚠️ 第 $attempt 次尝试失败',
|
||
tag: 'SingBox',
|
||
);
|
||
KRLogUtil.kr_w('❌ 错误类型: ${e.runtimeType}', tag: 'SingBox');
|
||
KRLogUtil.kr_w('❌ 错误信息: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_w('📚 错误堆栈: $stackTrace', tag: 'SingBox');
|
||
|
||
if (attempt >= maxAttempts) {
|
||
KRLogUtil.kr_e(
|
||
'❌ 命令客户端初始化失败,已达到最大重试次数 ($maxAttempts)',
|
||
tag: 'SingBox',
|
||
);
|
||
KRLogUtil.kr_e('💡 提示: command.sock 可能未准备好或权限不足', tag: 'SingBox');
|
||
throw Exception('命令客户端初始化失败: $e');
|
||
}
|
||
|
||
// 指数退避:每次失败后延迟翻倍(1000ms -> 2000ms -> 4000ms -> 8000ms)
|
||
delay = delay * 2;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 在后台尝试预连接 command client(不阻塞启动流程)
|
||
///
|
||
/// 借鉴 Hiddify 的设计思想:
|
||
/// - watchStats/watchGroups 是懒加载的,首次调用时才会 startCommandClient()
|
||
/// - 但我们可以在后台提前尝试连接,提高成功率
|
||
/// - 即使失败也不影响主流程,真正的订阅会在 UI 调用时发生
|
||
void _kr_tryPreconnectCommandClientsInBackground() {
|
||
KRLogUtil.kr_i('🔄 后台启动 command client 预连接任务...', tag: 'SingBox');
|
||
|
||
// 使用 Future.microtask 确保不阻塞当前执行
|
||
Future.microtask(() async {
|
||
try {
|
||
// 等待 command.sock 就绪(macOS 需要更长时间)
|
||
await Future.delayed(const Duration(milliseconds: 1500));
|
||
|
||
// 检查 socket 文件
|
||
final socketFile =
|
||
File(p.join(kr_configDics.baseDir.path, 'command.sock'));
|
||
if (!socketFile.existsSync()) {
|
||
KRLogUtil.kr_w('⚠️ command.sock 尚未创建,预连接取消', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ command.sock 已就绪,尝试预订阅...', tag: 'SingBox');
|
||
|
||
// ⚠️ 注意:这里只是"触发"订阅,不等待结果
|
||
// 如果失败,UI 调用时会重新尝试
|
||
try {
|
||
_kr_subscribeToStats();
|
||
KRLogUtil.kr_i('✅ 统计流预订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 统计流预订阅失败(正常,UI 调用时会重试): $e', tag: 'SingBox');
|
||
}
|
||
|
||
try {
|
||
_kr_subscribeToGroups();
|
||
KRLogUtil.kr_i('✅ 分组流预订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 分组流预订阅失败(正常,UI 调用时会重试): $e', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
// 静默失败,不影响主流程
|
||
KRLogUtil.kr_w('⚠️ 后台预连接任务失败(不影响正常使用): $e', tag: 'SingBox');
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 确保 command client 已初始化(通过触发订阅来初始化)
|
||
///
|
||
/// 借鉴 Hiddify:watchGroups() 会在首次调用时自动 startCommandClient()
|
||
Future<void> _kr_ensureCommandClientInitialized() async {
|
||
// 如果已经有订阅,说明 command client 已初始化
|
||
if (_kr_subscriptions.isNotEmpty) {
|
||
KRLogUtil.kr_i('✅ Command client 已初始化(订阅数: ${_kr_subscriptions.length})',
|
||
tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
KRLogUtil.kr_i('⚠️ Command client 未初始化,触发订阅...', tag: 'SingBox');
|
||
|
||
try {
|
||
// 触发 watchGroups(),这会自动调用 startCommandClient()
|
||
_kr_subscribeToGroups();
|
||
|
||
// 等待一小段时间让订阅建立
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
|
||
if (_kr_subscriptions.isEmpty) {
|
||
throw Exception('订阅失败,command client 未初始化');
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ Command client 初始化成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ Command client 初始化失败: $e', tag: 'SingBox');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 在后台恢复用户保存的节点选择(不阻塞启动流程)
|
||
///
|
||
/// selectOutbound() 依赖 command client,必须延迟执行
|
||
void _kr_restoreSavedNodeInBackground() {
|
||
KRLogUtil.kr_i('🔄 启动节点恢复后台任务...', tag: 'SingBox');
|
||
|
||
Future.microtask(() async {
|
||
try {
|
||
// 等待 command client 初始化
|
||
await Future.delayed(const Duration(milliseconds: 2000));
|
||
|
||
final savedNode =
|
||
await KRSecureStorage().kr_readData(key: _keySelectedNode);
|
||
if (savedNode != null && savedNode.isNotEmpty && savedNode != 'auto') {
|
||
KRLogUtil.kr_i('🔄 恢复用户选择的节点: $savedNode', tag: 'SingBox');
|
||
|
||
try {
|
||
await _kr_selectOutboundWithRetry("select", savedNode);
|
||
KRLogUtil.kr_i('✅ 节点恢复完成', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 节点恢复失败(可能 command client 未就绪): $e',
|
||
tag: 'SingBox');
|
||
}
|
||
} else {
|
||
KRLogUtil.kr_i('ℹ️ 使用默认节点选择 (auto)', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 节点恢复后台任务失败: $e', tag: 'SingBox');
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 监听活动组的详细实现
|
||
// Future<void> watchActiveGroups() async {
|
||
// try {
|
||
// print("开始监听活动组详情...");
|
||
|
||
// final status = await kr_singBox.status();
|
||
// print("服务状态: ${status.toJson()}");
|
||
|
||
// final outbounds = await kr_singBox.listOutbounds();
|
||
// print("出站列表: ${outbounds.toJson()}");
|
||
|
||
// for (var outbound in outbounds.outbounds) {
|
||
// print("出站配置: ${outbound.toJson()}");
|
||
|
||
// // 检查出站是否活动
|
||
// final isActive = await kr_singBox.isOutboundActive(outbound.tag);
|
||
// print("出站 ${outbound.tag} 活动状态: $isActive");
|
||
// }
|
||
// } catch (e, stack) {
|
||
// print("监听活动组详情时出错: $e");
|
||
// print("错误堆栈: $stack");
|
||
// }
|
||
// }
|
||
|
||
/// 保存配置文件
|
||
void kr_saveOutbounds(List<Map<String, dynamic>> outbounds) async {
|
||
KRLogUtil.kr_i('💾 开始保存配置文件...', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📊 出站节点数量: ${outbounds.length}', tag: 'SingBox');
|
||
|
||
// 打印每个节点的详细配置
|
||
// for (int i = 0; i < outbounds.length; i++) {
|
||
// final outbound = outbounds[i];
|
||
// KRLogUtil.kr_i('📋 节点[$i] 配置:', tag: 'SingBox');
|
||
// KRLogUtil.kr_i(' - type: ${outbound['type']}', tag: 'SingBox');
|
||
// KRLogUtil.kr_i(' - tag: ${outbound['tag']}', tag: 'SingBox');
|
||
// KRLogUtil.kr_i(' - server: ${outbound['server']}', tag: 'SingBox');
|
||
// KRLogUtil.kr_i(' - server_port: ${outbound['server_port']}',
|
||
// tag: 'SingBox');
|
||
// if (outbound['method'] != null) {
|
||
// KRLogUtil.kr_i(' - method: ${outbound['method']}', tag: 'SingBox');
|
||
// }
|
||
// if (outbound['interval'] != null) {
|
||
// KRLogUtil.kr_i(' - interval: ${outbound['interval']}', tag: 'SingBox');
|
||
// }
|
||
// if (outbound['password'] != null) {
|
||
// KRLogUtil.kr_i(
|
||
// ' - password: ${outbound['password']?.toString().substring(0, 8)}...',
|
||
// tag: 'SingBox');
|
||
// }
|
||
// if (outbound['uuid'] != null) {
|
||
// KRLogUtil.kr_i(
|
||
// ' - uuid: ${outbound['uuid']?.toString().substring(0, 8)}...',
|
||
// tag: 'SingBox');
|
||
// }
|
||
// KRLogUtil.kr_i(' - 完整配置: ${jsonEncode(outbound)}', tag: 'SingBox');
|
||
// }
|
||
|
||
// ⚠️ 临时过滤 Hysteria2 节点以避免 libcore 崩溃
|
||
kr_outbounds = outbounds.where((outbound) {
|
||
final type = outbound['type'];
|
||
if (type == 'hysteria2' || type == 'hysteria') {
|
||
KRLogUtil.kr_w('⚠️ 跳过 Hysteria2 节点: ${outbound['tag']} (libcore bug)',
|
||
tag: 'SingBox');
|
||
return false;
|
||
}
|
||
return true;
|
||
}).toList();
|
||
|
||
KRLogUtil.kr_i('✅ 过滤后节点数量: ${kr_outbounds.length}/${outbounds.length}',
|
||
tag: 'SingBox');
|
||
|
||
// 🔧 修复:生成完整的 SingBox 配置
|
||
// 之前只保存 {"outbounds": [...]}, 导致 libcore 无法正确处理
|
||
// 现在保存完整配置,包含所有必需字段
|
||
final Map<String, dynamic> fullConfig = {
|
||
"log": {"level": "debug", "timestamp": true},
|
||
"dns": {
|
||
"servers": [
|
||
{
|
||
"tag": "dns-remote",
|
||
"address": "https://1.1.1.1/dns-query",
|
||
"address_resolver": "dns-direct"
|
||
},
|
||
{"tag": "dns-direct", "address": "local", "detour": "direct"}
|
||
],
|
||
"rules": _kr_buildDnsRules(), // ✅ 使用动态构建的 DNS 规则
|
||
"final": "dns-remote",
|
||
"strategy": "prefer_ipv4"
|
||
},
|
||
"inbounds": [
|
||
{
|
||
"type": "tun",
|
||
"tag": "tun-in",
|
||
"interface_name": "utun",
|
||
"inet4_address": "172.19.0.1/30",
|
||
"auto_route": true,
|
||
"strict_route": true,
|
||
"sniff": true,
|
||
"sniff_override_destination": false
|
||
}
|
||
],
|
||
"outbounds": [
|
||
// 🔧 修复:添加 selector 组,让用户可以手动选择节点
|
||
{
|
||
"type": "selector",
|
||
"tag": "proxy",
|
||
"outbounds": kr_outbounds.map((o) => o['tag'] as String).toList(),
|
||
"default":
|
||
kr_outbounds.isNotEmpty ? kr_outbounds[0]['tag'] : "direct",
|
||
},
|
||
...kr_outbounds,
|
||
{"type": "direct", "tag": "direct"},
|
||
{"type": "block", "tag": "block"},
|
||
{"type": "dns", "tag": "dns-out"}
|
||
],
|
||
"route": {
|
||
"rules": _kr_buildRouteRules(), // ✅ 使用动态构建的路由规则
|
||
"rule_set": _kr_buildRuleSets(), // ✅ 使用动态构建的规则集
|
||
"final": "proxy", // 🔧 修复:使用 selector 组作为默认出站
|
||
"auto_detect_interface": true
|
||
}
|
||
};
|
||
|
||
final file = _file(kr_configName);
|
||
final temp = _tempFile(kr_configName);
|
||
final mapStr = jsonEncode(fullConfig);
|
||
|
||
KRLogUtil.kr_i('📄 完整配置文件长度: ${mapStr.length}', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📄 Outbounds 数量: ${kr_outbounds.length}', tag: 'SingBox');
|
||
KRLogUtil.kr_i(
|
||
'📄 配置前800字符:\n${mapStr.substring(0, mapStr.length > 800 ? 800 : mapStr.length)}',
|
||
tag: 'SingBox');
|
||
|
||
await file.writeAsString(mapStr);
|
||
await temp.writeAsString(mapStr);
|
||
|
||
_cutPath = file.path;
|
||
KRLogUtil.kr_i('📁 配置文件路径: $_cutPath', tag: 'SingBox');
|
||
|
||
await kr_singBox
|
||
.validateConfigByPath(file.path, temp.path, false)
|
||
.mapLeft((err) {
|
||
KRLogUtil.kr_e('❌ 保存配置文件失败: $err', tag: 'SingBox');
|
||
}).run();
|
||
|
||
KRLogUtil.kr_i('✅ 配置文件保存完成', tag: 'SingBox');
|
||
}
|
||
|
||
/// 构建 DNS 规则
|
||
List<Map<String, dynamic>> _kr_buildDnsRules() {
|
||
final currentCountryCode = KRCountryUtil.kr_getCurrentCountryCode();
|
||
final rules = <Map<String, dynamic>>[];
|
||
|
||
// 🔧 关键修复:只有在"智能代理"模式下,才根据国家添加直连规则
|
||
// 如果是"全局代理"模式,即使选择了国家也不添加直连规则
|
||
if (kr_connectionType.value == KRConnectionType.rule &&
|
||
currentCountryCode != 'other') {
|
||
rules.add({
|
||
"rule_set": [
|
||
"geoip-$currentCountryCode",
|
||
"geosite-$currentCountryCode",
|
||
],
|
||
"server": "dns-direct"
|
||
});
|
||
|
||
KRLogUtil.kr_i('✅ [智能代理模式] 添加 DNS 规则: $currentCountryCode 域名使用直连 DNS',
|
||
tag: 'SingBox');
|
||
} else if (kr_connectionType.value == KRConnectionType.global) {
|
||
KRLogUtil.kr_i('🌐 [全局代理模式] 跳过国家 DNS 规则,所有DNS查询走代理', tag: 'SingBox');
|
||
}
|
||
|
||
return rules;
|
||
}
|
||
|
||
/// 构建路由规则
|
||
List<Map<String, dynamic>> _kr_buildRouteRules() {
|
||
final currentCountryCode = KRCountryUtil.kr_getCurrentCountryCode();
|
||
final rules = <Map<String, dynamic>>[];
|
||
|
||
// 基础规则: DNS 查询走 dns-out
|
||
rules.add({"protocol": "dns", "outbound": "dns-out"});
|
||
|
||
// ✅ 自定义域名直连规则(优先级高,放在前面)
|
||
// rules.add({
|
||
// "domain_suffix": ["ip138.com"],
|
||
// "outbound": "direct"
|
||
// });
|
||
|
||
// KRLogUtil.kr_i('✅ 添加自定义域名直连规则: ip138.com -> direct', tag: 'SingBox');
|
||
|
||
// 🔧 关键修复:只有在"智能代理"模式下,才根据国家添加直连规则
|
||
// 如果是"全局代理"模式,即使选择了国家也不添加直连规则
|
||
if (kr_connectionType.value == KRConnectionType.rule &&
|
||
currentCountryCode != 'other') {
|
||
rules.add({
|
||
"rule_set": [
|
||
"geoip-$currentCountryCode",
|
||
"geosite-$currentCountryCode",
|
||
],
|
||
"outbound": "direct"
|
||
});
|
||
|
||
KRLogUtil.kr_i('✅ [智能代理模式] 添加路由规则: $currentCountryCode IP/域名直连',
|
||
tag: 'SingBox');
|
||
} else if (kr_connectionType.value == KRConnectionType.global) {
|
||
KRLogUtil.kr_i('🌐 [全局代理模式] 跳过国家路由规则,所有流量走代理', tag: 'SingBox');
|
||
}
|
||
|
||
return rules;
|
||
}
|
||
|
||
/// 构建规则集配置
|
||
List<Map<String, dynamic>> _kr_buildRuleSets() {
|
||
final currentCountryCode = KRCountryUtil.kr_getCurrentCountryCode();
|
||
final ruleSets = <Map<String, dynamic>>[];
|
||
|
||
// 🔧 关键修复:只有在"智能代理"模式下,才加载国家规则集
|
||
// 如果是"全局代理"模式,即使选择了国家也不加载规则集
|
||
if (kr_connectionType.value == KRConnectionType.rule &&
|
||
currentCountryCode != 'other') {
|
||
// 检查本地文件是否存在
|
||
final geoipFile = File(p.join(kr_configDics.workingDir.path, 'geosite',
|
||
'geoip-$currentCountryCode.srs'));
|
||
final geositeFile = File(p.join(kr_configDics.workingDir.path, 'geosite',
|
||
'geosite-$currentCountryCode.srs'));
|
||
|
||
if (geoipFile.existsSync() && geositeFile.existsSync()) {
|
||
// ✅ 使用本地文件
|
||
ruleSets.add({
|
||
"type": "local",
|
||
"tag": "geoip-$currentCountryCode",
|
||
"format": "binary",
|
||
"path": "./geosite/geoip-$currentCountryCode.srs" // 相对于 workingDir
|
||
});
|
||
|
||
ruleSets.add({
|
||
"type": "local",
|
||
"tag": "geosite-$currentCountryCode",
|
||
"format": "binary",
|
||
"path": "./geosite/geosite-$currentCountryCode.srs"
|
||
});
|
||
|
||
KRLogUtil.kr_i('✅ 使用本地规则集: $currentCountryCode', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - geoip: ./geosite/geoip-$currentCountryCode.srs',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_i(
|
||
' - geosite: ./geosite/geosite-$currentCountryCode.srs',
|
||
tag: 'SingBox');
|
||
} else {
|
||
// ❌ 本地文件不存在,使用远程规则集作为后备
|
||
KRLogUtil.kr_w('⚠️ 本地规则集不存在,使用远程规则集', tag: 'SingBox');
|
||
KRLogUtil.kr_w(' - geoip 文件存在: ${geoipFile.existsSync()}',
|
||
tag: 'SingBox');
|
||
KRLogUtil.kr_w(' - geosite 文件存在: ${geositeFile.existsSync()}',
|
||
tag: 'SingBox');
|
||
|
||
ruleSets.add({
|
||
"type": "remote",
|
||
"tag": "geoip-$currentCountryCode",
|
||
"format": "binary",
|
||
"url":
|
||
"https://raw.githubusercontent.com/hiddify/hiddify-geo/rule-set/country/geoip-$currentCountryCode.srs",
|
||
"download_detour": "direct",
|
||
"update_interval": "7d"
|
||
});
|
||
|
||
ruleSets.add({
|
||
"type": "remote",
|
||
"tag": "geosite-$currentCountryCode",
|
||
"format": "binary",
|
||
"url":
|
||
"https://raw.githubusercontent.com/hiddify/hiddify-geo/rule-set/country/geosite-$currentCountryCode.srs",
|
||
"download_detour": "direct",
|
||
"update_interval": "7d"
|
||
});
|
||
}
|
||
}
|
||
|
||
return ruleSets;
|
||
}
|
||
|
||
Future<void> kr_start() async {
|
||
// 不再手动设置状态,libcore 会通过 status stream 自动发送状态更新
|
||
try {
|
||
// 🔧 修复3: 添加登录状态检查 - 只有已登录用户才能连接VPN
|
||
if (!KRAppRunData().kr_isLogin.value) {
|
||
KRLogUtil.kr_e('❌ 未登录用户,禁止启动VPN连接', tag: 'SingBox');
|
||
throw Exception('用户未登录,无法启动VPN服务');
|
||
}
|
||
|
||
// ⚠️ 强制编译标记 - v2.0-lazy-load
|
||
KRLogUtil.kr_i('🚀🚀🚀 [v2.0-lazy-load] 开始启动 SingBox...', tag: 'SingBox');
|
||
|
||
// 🔧 强制重新生成配置文件(确保最新的路由规则生效)
|
||
if (kr_outbounds.isNotEmpty) {
|
||
KRLogUtil.kr_i('🔄 启动前强制重新生成配置文件...', tag: 'SingBox');
|
||
kr_saveOutbounds(kr_outbounds);
|
||
// 等待配置文件写入完成
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
}
|
||
|
||
KRLogUtil.kr_i('📁 配置文件路径: $_cutPath', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📝 配置名称: $kr_configName', tag: 'SingBox');
|
||
|
||
// 🔑 Windows 平台:仅在首次启动时备份 DNS 设置(优化:避免重复备份)
|
||
if (Platform.isWindows && !_dnsBackedUp) {
|
||
KRLogUtil.kr_i('🪟 Windows 平台,首次启动备份 DNS 设置...', tag: 'SingBox');
|
||
try {
|
||
final backupSuccess =
|
||
await KRWindowsDnsUtil.instance.kr_backupDnsSettings();
|
||
if (backupSuccess) {
|
||
_dnsBackedUp = true; // 标记已备份
|
||
KRLogUtil.kr_i('✅ Windows DNS 备份成功', tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_w('⚠️ Windows DNS 备份失败,将在停止时使用兜底恢复', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ Windows DNS 备份异常: $e,将在停止时使用兜底恢复', tag: 'SingBox');
|
||
}
|
||
} else if (Platform.isWindows && _dnsBackedUp) {
|
||
KRLogUtil.kr_i('⏭️ Windows 平台,DNS 已备份,跳过重复备份(节点切换优化)', tag: 'SingBox');
|
||
}
|
||
|
||
// 🔑 先尝试停止旧实例,避免 command.sock 冲突
|
||
// 注意:如果没有旧实例,libcore 会报错 "command.sock: no such file",这是正常的
|
||
try {
|
||
await kr_singBox.stop().run();
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
KRLogUtil.kr_i('✅ 已清理旧实例', tag: 'SingBox');
|
||
} catch (e) {
|
||
// 预期行为:没有旧实例时会报错,可以忽略
|
||
KRLogUtil.kr_i('ℹ️ 没有运行中的旧实例(正常)', tag: 'SingBox');
|
||
}
|
||
|
||
// 🔑 关键步骤:在 start 之前必须调用 changeOptions 初始化 HiddifyOptions
|
||
// 否则 libcore 的 StartService 会因为 HiddifyOptions == nil 而 panic
|
||
KRLogUtil.kr_i('📡 初始化 HiddifyOptions...', tag: 'SingBox');
|
||
final oOption = SingboxConfigOption.fromJson(_getConfigOption());
|
||
final changeResult = await kr_singBox.changeOptions(oOption).run();
|
||
changeResult.match(
|
||
(error) {
|
||
KRLogUtil.kr_e('❌ changeOptions() 失败: $error', tag: 'SingBox');
|
||
throw Exception('初始化 HiddifyOptions 失败: $error');
|
||
},
|
||
(_) {
|
||
KRLogUtil.kr_i('✅ HiddifyOptions 初始化成功', tag: 'SingBox');
|
||
},
|
||
);
|
||
|
||
// 检查配置文件是否存在
|
||
final configFile = File(_cutPath);
|
||
if (await configFile.exists()) {
|
||
final configContent = await configFile.readAsString();
|
||
KRLogUtil.kr_i('📄 配置文件内容长度: ${configContent.length}', tag: 'SingBox');
|
||
// KRLogUtil.kr_i(
|
||
// '📄 配置文件前500字符: ${configContent.substring(0, configContent.length > 500 ? 500 : configContent.length)}',
|
||
// tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_w('⚠️ 配置文件不存在: $_cutPath', tag: 'SingBox');
|
||
}
|
||
|
||
// 🔧 修复: 在启动前重新订阅状态流,确保能收到状态更新
|
||
_kr_subscribeToStatus();
|
||
|
||
await kr_singBox.start(_cutPath, kr_configName, false).map(
|
||
(r) {
|
||
KRLogUtil.kr_i('✅ SingBox 启动成功', tag: 'SingBox');
|
||
},
|
||
).mapLeft((err) {
|
||
KRLogUtil.kr_e('❌ SingBox 启动失败: $err', tag: 'SingBox');
|
||
// 不需要手动设置状态,libcore 会通过 status stream 自动发送 Stopped 事件
|
||
throw err;
|
||
}).run();
|
||
|
||
// ⚠️ 关键修复:在启动成功后立即订阅统计流
|
||
// 原因:
|
||
// 1. 统计流需要主动订阅才能接收数据
|
||
// 2. UI 只是读取 kr_stats.value,不会触发订阅
|
||
// 3. command.sock 在 start() 成功后会立即创建
|
||
|
||
KRLogUtil.kr_i('✅ SingBox 核心已启动,开始初始化 command client', tag: 'SingBox');
|
||
|
||
// 🔑 在后台延迟订阅统计流和分组流,避免阻塞 UI
|
||
Future.delayed(const Duration(milliseconds: 1000), () async {
|
||
try {
|
||
KRLogUtil.kr_i('📊 开始订阅统计数据流...', tag: 'SingBox');
|
||
_kr_subscribeToStats();
|
||
KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 统计数据流订阅失败(稍后重试): $e', tag: 'SingBox');
|
||
// 如果第一次失败,再等待一段时间重试
|
||
Future.delayed(const Duration(milliseconds: 2000), () {
|
||
try {
|
||
_kr_subscribeToStats();
|
||
KRLogUtil.kr_i('✅ 统计数据流重试订阅成功', tag: 'SingBox');
|
||
} catch (e2) {
|
||
KRLogUtil.kr_e('❌ 统计数据流重试订阅失败: $e2', tag: 'SingBox');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 🔧 关键修复:订阅分组数据流
|
||
try {
|
||
KRLogUtil.kr_i('📋 开始订阅分组数据流...', tag: 'SingBox');
|
||
_kr_subscribeToGroups();
|
||
KRLogUtil.kr_i('✅ 分组数据流订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 分组数据流订阅失败(稍后重试): $e', tag: 'SingBox');
|
||
// 如果第一次失败,再等待一段时间重试
|
||
Future.delayed(const Duration(milliseconds: 2000), () {
|
||
try {
|
||
_kr_subscribeToGroups();
|
||
KRLogUtil.kr_i('✅ 分组数据流重试订阅成功', tag: 'SingBox');
|
||
} catch (e2) {
|
||
KRLogUtil.kr_e('❌ 分组数据流重试订阅失败: $e2', tag: 'SingBox');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 🔧 关键修复:恢复用户选择的节点
|
||
try {
|
||
final selectedNode =
|
||
await KRSecureStorage().kr_readData(key: _keySelectedNode);
|
||
if (selectedNode != null && selectedNode.isNotEmpty) {
|
||
KRLogUtil.kr_i('🎯 恢复用户选择的节点: $selectedNode', tag: 'SingBox');
|
||
if (kDebugMode) {
|
||
print('🔵 启动后恢复节点选择: $selectedNode');
|
||
}
|
||
|
||
// 延迟500ms确保sing-box完全启动
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
|
||
// 🔧 关键修复:使用 await 等待节点切换完成
|
||
try {
|
||
await kr_selectOutbound(selectedNode);
|
||
KRLogUtil.kr_i('✅ 节点已切换到用户选择: $selectedNode', tag: 'SingBox');
|
||
if (kDebugMode) {
|
||
print('🔵 节点切换成功: $selectedNode');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 节点切换失败: $e', tag: 'SingBox');
|
||
if (kDebugMode) {
|
||
print('🔵 节点切换失败: $e');
|
||
}
|
||
}
|
||
} else {
|
||
KRLogUtil.kr_i('ℹ️ 没有保存的节点选择,使用默认配置', tag: 'SingBox');
|
||
if (kDebugMode) {
|
||
print('🔵 没有保存的节点选择,使用默认');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 恢复节点选择失败: $e', tag: 'SingBox');
|
||
if (kDebugMode) {
|
||
print('🔵 恢复节点选择失败: $e');
|
||
}
|
||
}
|
||
});
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('💥 SingBox 启动异常: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox');
|
||
// 不需要手动设置状态,libcore 会自动处理
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 停止服务
|
||
Future<void> kr_stop() async {
|
||
try {
|
||
KRLogUtil.kr_i('🛑 停止 SingBox 服务...', tag: 'SingBox');
|
||
|
||
// 取消节点选择监控定时器
|
||
kr_resetNodeSelectionMonitor();
|
||
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// 添加超时保护,防止 stop() 调用阻塞
|
||
// 🔧 延长超时时间到 10 秒,给 Windows DNS 清理足够时间
|
||
try {
|
||
await kr_singBox.stop().run().timeout(
|
||
const Duration(seconds: 10), // ← 从 3 秒延长到 10 秒
|
||
onTimeout: () {
|
||
KRLogUtil.kr_w('⚠️ 停止操作超时(10秒),强制继续', tag: 'SingBox');
|
||
return const Left('timeout');
|
||
},
|
||
);
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 停止操作失败(可能已经停止): $e', tag: 'SingBox');
|
||
// 继续执行清理操作
|
||
}
|
||
|
||
// 🔑 Windows 平台:仅在有备份时恢复 DNS 设置
|
||
if (Platform.isWindows && _dnsBackedUp) {
|
||
KRLogUtil.kr_i('🪟 Windows 平台,等待 sing-box 完全停止...', tag: 'SingBox');
|
||
|
||
// 🔧 P3优化: 监听状态而非固定延迟,确保 sing-box 真正停止后再恢复 DNS
|
||
try {
|
||
// 如果当前已经是停止状态,直接继续
|
||
if (kr_status.value is SingboxStopped) {
|
||
KRLogUtil.kr_i('✅ sing-box 已经是停止状态,立即恢复 DNS', tag: 'SingBox');
|
||
} else {
|
||
// 等待状态变为停止,最多等待3秒
|
||
final completer = Completer<void>();
|
||
late final Worker worker;
|
||
|
||
worker = ever(kr_status, (status) {
|
||
if (status is SingboxStopped) {
|
||
if (!completer.isCompleted) {
|
||
completer.complete();
|
||
worker.dispose();
|
||
}
|
||
}
|
||
});
|
||
|
||
await completer.future.timeout(
|
||
const Duration(seconds: 3),
|
||
onTimeout: () {
|
||
KRLogUtil.kr_w('⏱️ 等待停止状态超时,继续执行 DNS 恢复', tag: 'SingBox');
|
||
worker.dispose();
|
||
},
|
||
);
|
||
|
||
KRLogUtil.kr_i('✅ sing-box 已完全停止,开始恢复 DNS...', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 状态监听异常: $e,继续执行 DNS 恢复', tag: 'SingBox');
|
||
}
|
||
|
||
try {
|
||
// 尝试恢复 DNS
|
||
final restoreSuccess =
|
||
await KRWindowsDnsUtil.instance.kr_restoreDnsSettings();
|
||
if (restoreSuccess) {
|
||
KRLogUtil.kr_i('✅ Windows DNS 恢复成功', tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_e('❌ Windows DNS 恢复失败', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ Windows DNS 恢复异常: $e', tag: 'SingBox');
|
||
// 异常时也会在工具类内部执行兜底恢复
|
||
} finally {
|
||
// 🔧 P0修复2: 使用 finally 确保标志位始终被重置,避免下次启动跳过备份
|
||
_dnsBackedUp = false;
|
||
KRLogUtil.kr_i('🔄 重置 DNS 备份标志位', tag: 'SingBox');
|
||
}
|
||
} else if (Platform.isWindows && !_dnsBackedUp) {
|
||
KRLogUtil.kr_i('⏭️ Windows 平台,DNS 未备份,跳过恢复', tag: 'SingBox');
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
} else {
|
||
// 非 Windows 平台正常等待
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
}
|
||
|
||
// 取消统计和分组订阅,但保留状态订阅以便继续接收状态更新
|
||
final subscriptionsToCancel = _kr_subscriptions.where((sub) {
|
||
final hashStr = sub.hashCode.toString();
|
||
return !hashStr.contains('Status'); // 不取消状态订阅
|
||
}).toList();
|
||
|
||
for (var subscription in subscriptionsToCancel) {
|
||
try {
|
||
await subscription.cancel();
|
||
_kr_subscriptions.remove(subscription);
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('取消订阅时出错: $e');
|
||
}
|
||
}
|
||
|
||
// 不手动设置状态,由 libcore 通过 status stream 自动发送 Stopped 事件
|
||
KRLogUtil.kr_i('✅ SingBox 停止请求已发送', tag: 'SingBox');
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('停止服务时出错: $e');
|
||
KRLogUtil.kr_e('错误堆栈: $stackTrace');
|
||
|
||
// 🔑 即使出错,也要尝试恢复 Windows DNS
|
||
if (Platform.isWindows && _dnsBackedUp) {
|
||
KRLogUtil.kr_w('⚠️ 停止异常,强制执行 DNS 恢复', tag: 'SingBox');
|
||
try {
|
||
await KRWindowsDnsUtil.instance.kr_restoreDnsSettings();
|
||
} catch (dnsError) {
|
||
KRLogUtil.kr_e('❌ 强制 DNS 恢复失败: $dnsError', tag: 'SingBox');
|
||
} finally {
|
||
// 🔧 P0修复2: 异常路径也要重置标志位
|
||
_dnsBackedUp = false;
|
||
KRLogUtil.kr_i('🔄 异常后重置 DNS 备份标志位', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
// 不手动设置状态,信任 libcore 的状态管理
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
///
|
||
void kr_updateAdBlockEnabled(bool bl) async {
|
||
final oOption = _getConfigOption();
|
||
|
||
oOption["block-ads"] = bl;
|
||
final op = SingboxConfigOption.fromJson(oOption);
|
||
|
||
await kr_singBox.changeOptions(op)
|
||
..map((r) {}).mapLeft((err) {
|
||
KRLogUtil.kr_e('更新广告拦截失败: $err');
|
||
}).run();
|
||
if (kr_status.value == SingboxStarted()) {
|
||
await kr_restart();
|
||
}
|
||
kr_blockAds.value = bl;
|
||
}
|
||
|
||
Future<void> kr_restart() async {
|
||
KRLogUtil.kr_i('🔄 重启 SingBox...', tag: 'SingBox');
|
||
|
||
// 🔧 修复:使用 stop + start 而不是 restart
|
||
// 这样可以触发 Windows DNS 恢复和备份逻辑
|
||
try {
|
||
// 1. 先停止(会触发 DNS 恢复)
|
||
await kr_stop();
|
||
|
||
// 2. 等待完全停止
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
|
||
// 3. 重新启动(会触发 DNS 备份)
|
||
await kr_start();
|
||
|
||
KRLogUtil.kr_i('✅ SingBox 重启完成', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 重启失败: $e', tag: 'SingBox');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
//// 设置出站模式
|
||
Future<void> kr_updateConnectionType(KRConnectionType newType) async {
|
||
try {
|
||
KRLogUtil.kr_i('🔄 开始更新连接类型...', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📊 当前类型: ${kr_connectionType.value}', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📊 新类型: $newType', tag: 'SingBox');
|
||
|
||
if (kr_connectionType.value == newType) {
|
||
KRLogUtil.kr_i('⚠️ 连接类型相同,无需更新', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
kr_connectionType.value = newType;
|
||
|
||
final oOption = _getConfigOption();
|
||
|
||
var mode = "";
|
||
switch (newType) {
|
||
case KRConnectionType.global:
|
||
mode = "other";
|
||
KRLogUtil.kr_i('🌍 切换到全局代理模式', tag: 'SingBox');
|
||
break;
|
||
case KRConnectionType.rule:
|
||
mode = KRCountryUtil.kr_getCurrentCountryCode();
|
||
KRLogUtil.kr_i('🎯 切换到规则代理模式: $mode', tag: 'SingBox');
|
||
break;
|
||
// case KRConnectionType.direct:
|
||
// mode = "direct";
|
||
// break;
|
||
}
|
||
oOption["region"] = mode;
|
||
KRLogUtil.kr_i('📝 更新 region 配置: $mode', tag: 'SingBox');
|
||
|
||
final op = SingboxConfigOption.fromJson(oOption);
|
||
KRLogUtil.kr_i('📄 配置选项: ${oOption.toString()}', tag: 'SingBox');
|
||
|
||
await kr_singBox.changeOptions(op)
|
||
..map((r) {
|
||
KRLogUtil.kr_i('✅ 连接类型更新成功', tag: 'SingBox');
|
||
}).mapLeft((err) {
|
||
KRLogUtil.kr_e('❌ 更新连接类型失败: $err', tag: 'SingBox');
|
||
throw err;
|
||
}).run();
|
||
|
||
if (kr_status.value == SingboxStarted()) {
|
||
KRLogUtil.kr_i('🔄 VPN已启动,准备重启以应用新配置...', tag: 'SingBox');
|
||
await kr_restart();
|
||
KRLogUtil.kr_i('✅ VPN重启完成', tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_i('ℹ️ VPN未启动,配置已更新', tag: 'SingBox');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('💥 更新连接类型异常: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 更新国家设置
|
||
Future<void> kr_updateCountry(KRCountry kr_country) async {
|
||
// 如果国家相同,直接返回
|
||
if (kr_country.kr_code == KRCountryUtil.kr_getCurrentCountryCode()) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 更新工具类中的当前国家
|
||
await KRCountryUtil.kr_setCurrentCountry(kr_country);
|
||
// 更新配置选项
|
||
final oOption = _getConfigOption();
|
||
oOption["region"] = kr_country.kr_code;
|
||
final op = SingboxConfigOption.fromJson(oOption);
|
||
|
||
await kr_singBox.changeOptions(op)
|
||
..map((r) {}).mapLeft((err) {
|
||
KRLogUtil.kr_e('更新国家设置失败: $err');
|
||
}).run();
|
||
|
||
// 如果服务正在运行,重启服务
|
||
if (kr_status.value == SingboxStarted()) {
|
||
await kr_restart();
|
||
}
|
||
} catch (err) {
|
||
KRLogUtil.kr_e('更新国家失败: $err');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
Stream<SingboxStatus> kr_watchStatus() {
|
||
return kr_singBox.watchStatus();
|
||
}
|
||
|
||
Stream<List<SingboxOutboundGroup>> kr_watchGroups() {
|
||
return kr_singBox.watchGroups();
|
||
}
|
||
|
||
// 节点选择监控定时器
|
||
Timer? _nodeSelectionTimer;
|
||
|
||
Future<void> kr_selectOutbound(String tag) async {
|
||
KRLogUtil.kr_i('🎯 [v2.1] 开始选择出站节点: $tag', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📊 当前活动组数量: ${kr_activeGroups.length}', tag: 'SingBox');
|
||
|
||
// 如果活动组尚未就绪,等待一段时间;仍为空则仅保存选择,稍后恢复
|
||
if (kr_activeGroups.isEmpty) {
|
||
for (int i = 0; i < 20; i++) {
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
if (kr_activeGroups.isNotEmpty) break;
|
||
}
|
||
if (kr_activeGroups.isEmpty) {
|
||
KRLogUtil.kr_w('⚠️ 活动组为空,跳过即时切换,仅保存选择: $tag', tag: 'SingBox');
|
||
try {
|
||
await KRSecureStorage()
|
||
.kr_saveData(key: _keySelectedNode, value: tag);
|
||
KRLogUtil.kr_i('✅ 节点选择已保存: $tag', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'SingBox');
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 🔧 诊断:打印所有活动组的节点,确保目标节点存在
|
||
KRLogUtil.kr_i('🔍 搜索目标节点 "$tag" 在活动组中...', tag: 'SingBox');
|
||
bool foundNode = false;
|
||
for (var group in kr_activeGroups) {
|
||
for (var item in group.items) {
|
||
if (item.tag == tag) {
|
||
foundNode = true;
|
||
KRLogUtil.kr_i('✅ 在组 "${group.tag}" 中找到目标节点: $tag', tag: 'SingBox');
|
||
break;
|
||
}
|
||
}
|
||
if (foundNode) break;
|
||
}
|
||
if (!foundNode) {
|
||
KRLogUtil.kr_w('⚠️ 未能在任何活动组中找到目标节点: $tag', tag: 'SingBox');
|
||
// 打印所有可用的节点
|
||
for (var group in kr_activeGroups) {
|
||
for (var item in group.items) {
|
||
KRLogUtil.kr_d(' 可用节点: ${item.tag}', tag: 'SingBox');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🔧 关键修复:使用 await 确保保存完成
|
||
try {
|
||
await KRSecureStorage().kr_saveData(key: _keySelectedNode, value: tag);
|
||
KRLogUtil.kr_i('✅ 节点选择已保存: $tag', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'SingBox');
|
||
}
|
||
|
||
// 🔧 关键修复:使用 await 确保 command client 初始化完成
|
||
try {
|
||
await _kr_ensureCommandClientInitialized();
|
||
KRLogUtil.kr_i('✅ Command client 已就绪,执行节点切换', tag: 'SingBox');
|
||
|
||
// 🔧 关键修复:使用正确的 group tag
|
||
// libcore 生成的selector组的tag是"proxy"而不是"select"
|
||
final selectorGroupTag =
|
||
kr_activeGroups.any((g) => g.tag == 'select') ? 'select' : 'proxy';
|
||
KRLogUtil.kr_i('⏳ 调用 selectOutbound("$selectorGroupTag", "$tag")...',
|
||
tag: 'SingBox');
|
||
await _kr_selectOutboundWithRetry(selectorGroupTag, tag,
|
||
maxAttempts: 3, initialDelay: 50);
|
||
KRLogUtil.kr_i('✅ 节点切换API调用完成: $tag', tag: 'SingBox');
|
||
|
||
// 🔧 新增:验证节点切换是否生效
|
||
await Future.delayed(const Duration(milliseconds: 300)); // 等待活动组更新
|
||
await _kr_verifyNodeSelection(tag);
|
||
|
||
KRLogUtil.kr_i('✅ 节点切换验证完成: $tag', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 节点选择失败: $e', tag: 'SingBox');
|
||
rethrow; // 抛出异常,让调用者知道失败了
|
||
}
|
||
|
||
// 🔄 如果用户选择了具体节点(不是 auto),启动定期检查和重新选择
|
||
// 这是为了防止 urltest 自动覆盖用户的手动选择
|
||
kr_startNodeSelectionMonitor(tag);
|
||
}
|
||
|
||
/// 配合文件地址
|
||
|
||
Directory get directory =>
|
||
Directory(p.join(kr_configDics.workingDir.path, "configs"));
|
||
File _file(String fileName) {
|
||
return File(p.join(directory.path, "$fileName.json"));
|
||
}
|
||
|
||
File _tempFile(String fileName) => _file("$fileName.tmp");
|
||
|
||
/// 启动节点选择监控定时器,防止用户手动选择被自动策略覆盖
|
||
void kr_startNodeSelectionMonitor(String tag) {
|
||
kr_resetNodeSelectionMonitor();
|
||
if (tag != 'auto') {
|
||
KRLogUtil.kr_i('🔁 启动节点选择监控,防止被 auto 覆盖', tag: 'SingBox');
|
||
_nodeSelectionTimer =
|
||
Timer.periodic(const Duration(seconds: 20), (timer) {
|
||
// 每 20 秒重新选择一次,确保用户选择不被覆盖
|
||
// 使用 then/catchError 避免异常导致 UI 阻塞
|
||
kr_singBox.selectOutbound("select", tag).run().then((result) {
|
||
result.match(
|
||
(error) => KRLogUtil.kr_w('🔁 定时器重选节点失败: $error', tag: 'SingBox'),
|
||
(_) => KRLogUtil.kr_d('🔁 定时器重选节点成功', tag: 'SingBox'),
|
||
);
|
||
}).catchError((error) {
|
||
KRLogUtil.kr_w('🔁 定时器重选节点异常: $error', tag: 'SingBox');
|
||
});
|
||
KRLogUtil.kr_d('🔁 重新确认节点选择: $tag', tag: 'SingBox');
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 重置并停止节点选择监控定时器
|
||
void kr_resetNodeSelectionMonitor() {
|
||
_nodeSelectionTimer?.cancel();
|
||
_nodeSelectionTimer = null;
|
||
KRLogUtil.kr_i('✅ 节点选择监控已停止', tag: 'SingBox');
|
||
}
|
||
|
||
// File tempFile(String fileName) => file("$fileName.tmp");
|
||
|
||
Future<void> kr_urlTest(String groupTag) async {
|
||
KRLogUtil.kr_i('🧪 开始 URL 测试: $groupTag', tag: 'SingBox');
|
||
KRLogUtil.kr_i('📊 当前活动组数量: ${kr_activeGroups.length}', tag: 'SingBox');
|
||
|
||
// 打印所有活动组信息
|
||
for (int i = 0; i < kr_activeGroups.length; i++) {
|
||
final group = kr_activeGroups[i];
|
||
KRLogUtil.kr_i(
|
||
'📋 活动组[$i]: tag=${group.tag}, type=${group.type}, selected=${group.selected}',
|
||
tag: 'SingBox');
|
||
for (int j = 0; j < group.items.length; j++) {
|
||
final item = group.items[j];
|
||
KRLogUtil.kr_i(
|
||
' └─ 节点[$j]: tag=${item.tag}, type=${item.type}, delay=${item.urlTestDelay}',
|
||
tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
try {
|
||
KRLogUtil.kr_i('🚀 调用 SingBox URL 测试 API...', tag: 'SingBox');
|
||
final result = await kr_singBox.urlTest(groupTag).run();
|
||
KRLogUtil.kr_i('✅ URL 测试完成: $groupTag, 结果: $result', tag: 'SingBox');
|
||
|
||
// 等待一段时间让 SingBox 完成测试
|
||
await Future.delayed(const Duration(seconds: 2));
|
||
|
||
// 再次检查活动组状态
|
||
KRLogUtil.kr_i('🔄 测试后活动组状态检查:', tag: 'SingBox');
|
||
for (int i = 0; i < kr_activeGroups.length; i++) {
|
||
final group = kr_activeGroups[i];
|
||
KRLogUtil.kr_i(
|
||
'📋 活动组[$i]: tag=${group.tag}, type=${group.type}, selected=${group.selected}',
|
||
tag: 'SingBox');
|
||
for (int j = 0; j < group.items.length; j++) {
|
||
final item = group.items[j];
|
||
KRLogUtil.kr_i(
|
||
' └─ 节点[$j]: tag=${item.tag}, type=${item.type}, delay=${item.urlTestDelay}',
|
||
tag: 'SingBox');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ URL 测试失败: $groupTag, 错误: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_e('📚 错误详情: ${e.toString()}', tag: 'SingBox');
|
||
}
|
||
}
|
||
}
|