3233 lines
136 KiB
Dart
Executable File
3233 lines
136 KiB
Dart
Executable File
import 'dart:convert';
|
||
import 'dart:io';
|
||
import 'dart:async';
|
||
import 'dart:ffi';
|
||
// ✅ dart:isolate 已移除(原用于 DNS 恢复的 Isolate,现由 libcore 管理)
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:fpdart/fpdart.dart';
|
||
import 'package:get/get.dart';
|
||
import 'package:synchronized/synchronized.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_file_logger.dart';
|
||
import '../../utils/kr_secure_storage.dart';
|
||
// ✅ DNS 由 libcore.dll 自动管理,已移除 kr_windows_dns_util.dart
|
||
import '../../utils/kr_windows_process_util.dart';
|
||
import '../../common/app_run_data.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
enum KRConnectionType {
|
||
global,
|
||
rule,
|
||
// direct,
|
||
}
|
||
|
||
// 🔧 P0-1: 订阅类型标识 - 用于精确管理订阅
|
||
class _SubscriptionType {
|
||
static const String groups = 'groups';
|
||
static const String stats = 'stats';
|
||
static const String status = 'status';
|
||
}
|
||
|
||
// 🔧 _WindowsProxyRestoreWorker 类已删除 - 改用 WinINet API 直接在主线程执行(无需 Isolate)
|
||
// 优势:
|
||
// ✅ 毫秒级速度(无进程启动开销)
|
||
// ✅ 无黑窗问题
|
||
// ✅ 代码更简洁
|
||
|
||
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;
|
||
|
||
// Windows 下 old command server 使用固定 TCP 端口(libbox hard-coded: 127.0.0.1:8964)
|
||
static const int _krWindowsCommandServerPort = 8964;
|
||
|
||
/// 统计
|
||
final kr_stats = SingboxStats(
|
||
connectionsIn: 0,
|
||
connectionsOut: 0,
|
||
uplink: 0,
|
||
downlink: 0,
|
||
uplinkTotal: 0,
|
||
downlinkTotal: 0,
|
||
).obs;
|
||
|
||
/// 活动的出站分组
|
||
RxList<SingboxOutboundGroup> kr_activeGroups = <SingboxOutboundGroup>[].obs;
|
||
|
||
// ✅ Windows 管理员权限检查缓存(避免重复执行系统命令)
|
||
bool? _cachedAdminPrivilege;
|
||
DateTime? _cachedAdminPrivilegeTime;
|
||
static const Duration _adminPrivilegeCacheDuration = Duration(hours: 1);
|
||
|
||
// ✅ DNS 和系统代理都由 Go 后端 (libcore.dll) 自动管理
|
||
// 参考 Hiddify 实现:Dart 层不再手动处理 DNS 和代理设置
|
||
// sing-box 内部有完整的 DNS 代理机制(local-dns-port、remote-dns-address 等)
|
||
|
||
/// 所有的出站分组
|
||
RxList<SingboxOutboundGroup> kr_allGroups = <SingboxOutboundGroup>[].obs;
|
||
|
||
/// Stream 订阅管理器
|
||
final List<StreamSubscription<dynamic>> _kr_subscriptions = [];
|
||
|
||
// 🔧 P0-1 + P2-11: 改进订阅管理 - 使用 Map 而非 List,防止重复订阅
|
||
// P2-11: hashCode 去重不可靠 - 改为使用类型安全的 Map,避免基于 hashCode.toString().contains() 的脆弱判断
|
||
/// 精确的订阅管理(Map)
|
||
final Map<String, StreamSubscription<dynamic>> _subscriptionMap = {};
|
||
|
||
// ✅ 系统代理操作已移交给 Go 后端 (libcore.dll) 处理
|
||
// 不再需要 Dart 层的代理操作互斥锁
|
||
|
||
/// 初始化标志,防止重复初始化
|
||
bool _kr_isInitialized = false;
|
||
|
||
// 🔧 P1-3: 命令客户端初始化锁,防止并发调用导致重复初始化
|
||
final Lock _commandClientInitLock = Lock();
|
||
|
||
// 🔧 防止 kr_start() 和 kr_stop() 并发执行导致卡顿
|
||
// 快速点击VPN开关时,通过此 Lock 确保上一个操作完成后才能开始新操作
|
||
final Lock _startStopLock = Lock();
|
||
|
||
// 【关键】停止请求标志
|
||
// 用于通知后台任务停止执行,避免竞态条件
|
||
// 当 kr_stop() 开始时设置为 true,让正在执行或即将执行的后台任务立即返回
|
||
bool _stopRequested = false;
|
||
|
||
// ✅ DNS 相关的方法已移除
|
||
// 参考 Hiddify 实现:sing-box 内部有完整的 DNS 代理机制
|
||
// 通过 local-dns-port、remote-dns-address、direct-dns-address 等配置处理 DNS
|
||
|
||
/// 当前混合代理端口是否就绪
|
||
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 = false}) {
|
||
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;
|
||
}
|
||
|
||
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 发送消息
|
||
await KRFileLogger.log('[黑屏调试] ⏳ 准备调用 FFI setup()...');
|
||
KRLogUtil.kr_i('📡 开始调用 setup() 注册 FFI 端口', tag: 'SingBox');
|
||
final setupStartTime = DateTime.now();
|
||
final setupResult = await kr_singBox.setup(kr_configDics, false).run();
|
||
final setupDuration = DateTime.now().difference(setupStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[黑屏调试] ✅ FFI setup() 完成: 耗时 ${setupDuration}ms');
|
||
setupResult.match(
|
||
(error) async {
|
||
await KRFileLogger.log('[黑屏调试] ❌ FFI setup() 失败: $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();
|
||
// ✅ DNS 由 libcore.dll 自动管理,无需手动备份
|
||
KRLogUtil.kr_i('✅ 状态订阅已设置', tag: 'SingBox');
|
||
} 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;
|
||
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 useWindowsDnsDefaults = Platform.isWindows;
|
||
final remoteDnsAddress = useWindowsDnsDefaults
|
||
? 'udp://1.1.1.1'
|
||
: 'https://dns.google/dns-query';
|
||
final directDnsAddress = useWindowsDnsDefaults
|
||
? (effectiveRegion == 'cn' ? 'udp://223.5.5.5' : 'udp://1.1.1.1')
|
||
: 'local';
|
||
final dnsDomainStrategy = useWindowsDnsDefaults ? '' : 'prefer_ipv4';
|
||
const enableDnsRouting = true;
|
||
|
||
final op = {
|
||
"region": effectiveRegion, // 🔧 修复:根据出站模式动态设置 region
|
||
"block-ads": false, // 参考 hiddify-app: 默认关闭广告拦截
|
||
"use-xray-core-when-possible": false,
|
||
"execute-config-as-is": false, // ✅ 按 hiddify-app 方式交给 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": remoteDnsAddress, // 使用 Google DoH,避免中转节点 DNS 死锁
|
||
"remote-dns-domain-strategy": dnsDomainStrategy,
|
||
"direct-dns-address": directDnsAddress, // 使用系统 DNS,确保中转服务器域名能被解析
|
||
"direct-dns-domain-strategy": dnsDomainStrategy,
|
||
"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,
|
||
// 🔧 关键改动:根据连接类型选择使用 TUN 还是系统代理
|
||
// 全局代理(global) → 启用 TUN 模式(强制代理,Chrome 插件无法劫持)
|
||
// 智能代理(rule) → 使用系统代理(智能分流)
|
||
// ✅ 参考 Hiddify:Windows/macOS/Linux 全局模式都启用 TUN
|
||
"enable-tun": (Platform.isIOS || Platform.isAndroid) ||
|
||
((Platform.isWindows || Platform.isMacOS || Platform.isLinux) && kr_connectionType.value == KRConnectionType.global),
|
||
"enable-tun-service": false,
|
||
"set-system-proxy":
|
||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS) &&
|
||
kr_connectionType.value != KRConnectionType.global,
|
||
"bypass-lan": false,
|
||
"allow-connection-from-lan": false,
|
||
"enable-fake-dns": false,
|
||
"enable-dns-routing": enableDnsRouting,
|
||
"independent-dns-cache": true,
|
||
"rules": [
|
||
// ✅ 自定义域名直连规则 - 添加到 HiddifyOptions.Rules 中
|
||
// 🔧 修复: 空规则列表,避免 "missing conditions" 错误
|
||
// 如果需要添加规则,必须确保所有必需字段都存在
|
||
],
|
||
"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) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 _kr_subscribeToStatus 被调用,重新订阅状态流');
|
||
}
|
||
KRLogUtil.kr_i('🔵 _kr_subscribeToStatus 被调用', tag: 'SingBox');
|
||
|
||
// 取消之前的状态订阅
|
||
for (var sub in _kr_subscriptions) {
|
||
if (sub.hashCode.toString().contains('Status')) {
|
||
sub.cancel();
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 已取消旧的状态订阅');
|
||
}
|
||
}
|
||
}
|
||
_kr_subscriptions
|
||
.removeWhere((sub) => sub.hashCode.toString().contains('Status'));
|
||
|
||
_kr_subscriptions.add(
|
||
kr_singBox.watchStatus().listen(
|
||
(status) {
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 收到 Native 状态更新: ${status.runtimeType}');
|
||
}
|
||
KRLogUtil.kr_i('📡 收到状态更新: $status', tag: 'SingBox');
|
||
kr_status.value = status;
|
||
// ✅ 系统代理由 Go 后端 (libcore.dll) 自动管理
|
||
// libcore 在 stop() 时会自动恢复系统代理,无需在 Dart 层处理
|
||
},
|
||
onError: (error) {
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 状态流错误: $error');
|
||
}
|
||
KRLogUtil.kr_e('📡 状态流错误: $error', tag: 'SingBox');
|
||
},
|
||
cancelOnError: false,
|
||
),
|
||
);
|
||
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 状态流订阅完成');
|
||
}
|
||
}
|
||
|
||
/// 订阅统计数据流
|
||
/// 🔧 UI阻塞修复:改为异步方法,在FFI调用前让出控制权
|
||
Future<void> _kr_subscribeToStats() async {
|
||
// 🔧 P0-1: 先取消旧的 stats 订阅
|
||
_subscriptionMap[_SubscriptionType.stats]?.cancel();
|
||
|
||
// 移除列表中的旧订阅(向后兼容)
|
||
for (var sub in _kr_subscriptions) {
|
||
if (sub.hashCode.toString().contains('Stats')) {
|
||
sub.cancel();
|
||
}
|
||
}
|
||
_kr_subscriptions
|
||
.removeWhere((sub) => sub.hashCode.toString().contains('Stats'));
|
||
|
||
// 🔧 关键修复:在调用同步FFI之前让出控制权,给UI一帧渲染时间
|
||
await Future.delayed(Duration.zero);
|
||
|
||
// ⚠️ 关键: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,
|
||
);
|
||
|
||
// 🔧 P0-1: 使用 Map 精确管理订阅
|
||
_subscriptionMap[_SubscriptionType.stats] = subscription;
|
||
_kr_subscriptions.add(subscription);
|
||
|
||
KRLogUtil.kr_i('✅ Stats 订阅已建立', tag: 'SingBox');
|
||
}
|
||
|
||
/// 订阅分组数据流
|
||
/// 🔧 UI阻塞修复:改为异步方法,在FFI调用前让出控制权
|
||
Future<void> _kr_subscribeToGroups() async {
|
||
KRFileLogger.log('[黑屏调试] [_kr_subscribeToGroups] 🚀 开始订阅分组数据流');
|
||
|
||
// 🔧 P0-1: 先取消旧的 groups 订阅
|
||
_subscriptionMap[_SubscriptionType.groups]?.cancel();
|
||
|
||
// 移除列表中的旧订阅(向后兼容)
|
||
for (var sub in _kr_subscriptions) {
|
||
if (sub.hashCode.toString().contains('Groups')) {
|
||
sub.cancel();
|
||
}
|
||
}
|
||
_kr_subscriptions
|
||
.removeWhere((sub) => sub.hashCode.toString().contains('Groups'));
|
||
|
||
// 🔧 关键修复:在调用同步FFI之前让出控制权,给UI一帧渲染时间
|
||
await Future.delayed(Duration.zero);
|
||
|
||
// 订阅活动分组
|
||
final activeGroupsSubscription = kr_singBox.watchActiveGroups().listen(
|
||
(groups) {
|
||
KRFileLogger.log('[黑屏调试] [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) {
|
||
KRFileLogger.log('[黑屏调试] [watchActiveGroups] ❌ 活动分组监听错误: $error');
|
||
KRLogUtil.kr_e('❌ 活动分组监听错误: $error', tag: 'SingBox');
|
||
},
|
||
cancelOnError: false,
|
||
);
|
||
_kr_subscriptions.add(activeGroupsSubscription);
|
||
|
||
// 🔧 关键修复:在两个FFI调用之间让出控制权,避免连续阻塞UI
|
||
await Future.delayed(Duration.zero);
|
||
|
||
// 订阅所有分组
|
||
final allGroupsSubscription = kr_singBox.watchGroups().listen(
|
||
(groups) {
|
||
KRFileLogger.log('[黑屏调试] [watchGroups] 📡 收到所有组更新,数量: ${groups.length}');
|
||
kr_allGroups.value = groups;
|
||
// 打印每个组的基本信息
|
||
for (int i = 0; i < groups.length; i++) {
|
||
final group = groups[i];
|
||
KRFileLogger.log('[黑屏调试] [watchGroups] 组[$i]: tag=${group.tag}, type=${group.type}, selected=${group.selected}');
|
||
}
|
||
},
|
||
onError: (error) {
|
||
KRFileLogger.log('[黑屏调试] [watchGroups] ❌ 所有分组监听错误: $error');
|
||
KRLogUtil.kr_e('所有分组监听错误: $error');
|
||
},
|
||
cancelOnError: false,
|
||
);
|
||
_kr_subscriptions.add(allGroupsSubscription);
|
||
|
||
// 🔧 P0-1: 使用 Map 精确管理订阅(将两个都归为 'groups' 类型,因为它们共享生命周期)
|
||
_subscriptionMap[_SubscriptionType.groups] = activeGroupsSubscription;
|
||
|
||
KRFileLogger.log('[黑屏调试] [_kr_subscribeToGroups] ✅ 分组数据流订阅完成,当前订阅数: ${_kr_subscriptions.length}');
|
||
KRLogUtil.kr_i('✅ Groups 订阅已建立', 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;
|
||
|
||
// 🔧 UI阻塞修复:订阅方法现在是异步的,会在FFI调用前让出控制权
|
||
try {
|
||
KRLogUtil.kr_i('📊 订阅统计数据流...', tag: 'SingBox');
|
||
await _kr_subscribeToStats(); // 🔧 修复:使用await调用异步方法
|
||
statsSubscribed = true;
|
||
KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 统计数据流订阅失败: $e', tag: 'SingBox');
|
||
}
|
||
|
||
try {
|
||
KRLogUtil.kr_i('📋 订阅分组数据流...', tag: 'SingBox');
|
||
await _kr_subscribeToGroups(); // 🔧 修复:使用await调用异步方法
|
||
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阻塞修复:订阅方法现在是异步的,会在FFI调用前让出控制权
|
||
// 如果失败,UI 调用时会重新尝试
|
||
try {
|
||
await _kr_subscribeToStats(); // 🔧 修复:使用await
|
||
KRLogUtil.kr_i('✅ 统计流预订阅成功', tag: 'SingBox');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 统计流预订阅失败(正常,UI 调用时会重试): $e', tag: 'SingBox');
|
||
}
|
||
|
||
try {
|
||
await _kr_subscribeToGroups(); // 🔧 修复:使用await
|
||
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 {
|
||
// 🔧 P0-1 + P2新: 改进订阅检查 - 检查是否真正有 groups 和 stats 订阅
|
||
// 而不是仅检查列表是否非空(Status 订阅也会在列表中,容易误判)
|
||
final hasGroupsSubscription = _subscriptionMap[_SubscriptionType.groups] != null;
|
||
final hasStatsSubscription = _subscriptionMap[_SubscriptionType.stats] != null;
|
||
|
||
if (hasGroupsSubscription && hasStatsSubscription) {
|
||
KRLogUtil.kr_i('✅ Command client 已初始化(groups: ✓, stats: ✓)', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
KRLogUtil.kr_i('⚠️ Command client 未初始化或订阅丢失,重新初始化...', tag: 'SingBox');
|
||
|
||
// 🔧 P1-3: 使用 Mutex 保护初始化过程,防止并发调用导致重复初始化(内存泄漏)
|
||
await _commandClientInitLock.synchronized(() async {
|
||
try {
|
||
// 再次检查(double-check locking 模式,避免在等待锁期间被其他线程初始化)
|
||
final hasGroupsSubscription = _subscriptionMap[_SubscriptionType.groups] != null;
|
||
final hasStatsSubscription = _subscriptionMap[_SubscriptionType.stats] != null;
|
||
|
||
if (hasGroupsSubscription && hasStatsSubscription) {
|
||
KRLogUtil.kr_i('✅ Command client 在等待期间已被初始化,跳过重复初始化', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
// 🔧 UI阻塞修复:订阅方法现在是异步的,会在FFI调用前让出控制权
|
||
await _kr_subscribeToGroups(); // 🔧 修复:使用await
|
||
await _kr_subscribeToStats(); // 🔧 修复:使用await
|
||
|
||
// 等待订阅建立完成
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// 验证订阅是否真的建立了
|
||
if (_subscriptionMap[_SubscriptionType.groups] == null ||
|
||
_subscriptionMap[_SubscriptionType.stats] == null) {
|
||
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['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');
|
||
|
||
// Use hiddify-app flow: save only base outbounds.
|
||
// libcore builds full config from HiddifyOptions.
|
||
final Map<String, dynamic> baseConfig = {
|
||
"outbounds": [
|
||
if (kr_outbounds.isNotEmpty)
|
||
{
|
||
"type": "selector",
|
||
"tag": "proxy",
|
||
"outbounds": kr_outbounds.map((o) => o['tag'] as String).toList(),
|
||
"default": kr_outbounds[0]['tag'],
|
||
},
|
||
...kr_outbounds,
|
||
],
|
||
};
|
||
|
||
final file = _file(kr_configName);
|
||
final temp = _tempFile(kr_configName);
|
||
final mapStr = jsonEncode(baseConfig);
|
||
|
||
KRLogUtil.kr_i('Config length: ${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');
|
||
|
||
// Align with hiddify-app: send HiddifyOptions before validate.
|
||
final oOption = SingboxConfigOption.fromJson(_getConfigOption());
|
||
await kr_singBox.changeOptions(oOption).mapLeft((err) {
|
||
KRLogUtil.kr_e('Config changeOptions failed before validate: $err', tag: 'SingBox');
|
||
}).run();
|
||
|
||
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"
|
||
});
|
||
|
||
// 🔧 关键修复:只有在"智能代理"模式下,才根据国家添加直连规则
|
||
// 如果是"全局代理"模式,即使选择了国家也不添加直连规则
|
||
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 {
|
||
// 🔧 文件日志:记录 kr_start() 调用 + 时间戳
|
||
final clickTime = DateTime.now();
|
||
await KRFileLogger.log('[黑屏调试] [DEBUG-START] kr_start() 方法被调用 - $clickTime');
|
||
await KRFileLogger.log('[kr_start] ⏱️ 用户点击启动 VPN - $clickTime');
|
||
|
||
// 🔧 P0-3: 防止重复启动,避免备份被覆盖
|
||
if (kr_status.value is SingboxStarted) {
|
||
KRLogUtil.kr_w('⚠️ VPN 已在运行,请勿重复启动', tag: 'SingBox');
|
||
await KRFileLogger.log('[kr_start] VPN 已在运行,返回');
|
||
await KRFileLogger.log('[黑屏调试] [DEBUG-START] VPN 已在运行,直接返回');
|
||
return;
|
||
}
|
||
|
||
// 🔧 文件日志:等待 Lock + 时间戳
|
||
final beforeLock = DateTime.now();
|
||
await KRFileLogger.log('[黑屏调试] [DEBUG-START] 准备等待 Lock - $beforeLock');
|
||
await KRFileLogger.log('[kr_start] ⏱️ 开始等待 Lock... - $beforeLock');
|
||
await KRFileLogger.log('[黑屏调试] [DEBUG-START] 已打印等待 Lock 日志 - ${DateTime.now()}');
|
||
|
||
// 🔧 VPN 开关防卡顿:使用 Mutex 确保 kr_start() 和 kr_stop() 不会并发执行
|
||
// 快速连续点击 VPN 开关时,此 Lock 会序列化所有操作,防止系统过载
|
||
return _startStopLock.synchronized(() async {
|
||
final afterLock = DateTime.now();
|
||
final lockWaitMs = afterLock.difference(beforeLock).inMilliseconds;
|
||
|
||
// 🔍 诊断:Lock 等待时间检查
|
||
if (lockWaitMs > 1000) {
|
||
await KRFileLogger.log('[kr_start] 🚨 【关键诊断】Lock 等待超过 1000ms(等待 ${lockWaitMs}ms)!');
|
||
await KRFileLogger.log('[kr_start] 🚨 这说明前一个操作(kr_stop)还没释放 Lock,可能被后台任务阻塞!');
|
||
await KRFileLogger.log('[kr_start] 🚨 可能原因:后台 DNS/代理恢复或者后台订阅任务被阻塞');
|
||
} else {
|
||
await KRFileLogger.log('[kr_start] ✅ Lock 已获取(等待 ${lockWaitMs}ms,正常)');
|
||
}
|
||
await KRFileLogger.log('[kr_start] ✅ Lock 已获取(等待 ${lockWaitMs}ms)- $afterLock');
|
||
|
||
try {
|
||
final beforeInternal = DateTime.now();
|
||
await _kr_startInternal();
|
||
final afterInternal = DateTime.now();
|
||
final internalMs = afterInternal.difference(beforeInternal).inMilliseconds;
|
||
await KRFileLogger.log('[kr_start] ✅ _kr_startInternal() 完成(耗时 ${internalMs}ms)- $afterInternal');
|
||
} catch (e) {
|
||
await KRFileLogger.log('[kr_start] ❌ _kr_startInternal() 异常: $e - ${DateTime.now()}');
|
||
rethrow;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 实际的启动逻辑(被 Lock 保护)
|
||
Future<void> _kr_startInternal() async {
|
||
// ✅ DNS 由 libcore.dll 自动管理
|
||
// 再次检查状态(Lock 内部二次检查)
|
||
if (kr_status.value is SingboxStarted) {
|
||
KRLogUtil.kr_i('✅ VPN 已在运行(Lock 内检查)', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
// 不再手动设置状态,libcore 会通过 status stream 自动发送状态更新
|
||
try {
|
||
// 🔧 修复3: 添加登录状态检查 - 只有已登录用户才能连接VPN
|
||
if (!KRAppRunData().kr_isLogin.value) {
|
||
KRLogUtil.kr_e('❌ 未登录用户,禁止启动VPN连接', tag: 'SingBox');
|
||
throw Exception('用户未登录,无法启动VPN服务');
|
||
}
|
||
|
||
// ✅ 改进:应用启动时已由 manifest 要求管理员权限(UAC 提升)
|
||
// 用户同意 UAC 提示后,应用以管理员身份运行,可以直接使用 TUN 模式
|
||
// 无需在运行时再做权限检查
|
||
if (Platform.isWindows && kr_connectionType.value == KRConnectionType.global) {
|
||
KRLogUtil.kr_i('✅ 应用已以管理员身份运行,可以使用全局代理模式(TUN)', tag: 'SingBox');
|
||
}
|
||
|
||
// 🪟 Windows 预检查:避免 command server 固定端口 8964 被占用导致启动失败
|
||
// ⚠️ 强制编译标记 - 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');
|
||
|
||
// ✅ DNS 由 sing-box 内部代理机制处理,不需要手动备份/恢复
|
||
// 参考 Hiddify 实现:sing-box 有 local-dns-port、remote-dns-address 等 DNS 配置
|
||
|
||
// 🔑 先尝试停止旧实例,避免 command.sock 冲突
|
||
// 注意:如果没有旧实例,libcore 会报错 "command.sock: no such file",这是正常的
|
||
try {
|
||
await KRFileLogger.log('[_kr_startInternal] 开始停止旧实例 - ${DateTime.now()}');
|
||
await kr_singBox.stop().run();
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
KRLogUtil.kr_i('✅ 已清理旧实例', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_startInternal] 旧实例已停止 - ${DateTime.now()}');
|
||
} catch (e) {
|
||
// 预期行为:没有旧实例时会报错,可以忽略
|
||
KRLogUtil.kr_i('ℹ️ 没有运行中的旧实例(正常)', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_startInternal] 旧实例不存在(正常)- ${DateTime.now()}');
|
||
}
|
||
|
||
// 🔑 关键步骤:在 start 之前必须调用 changeOptions 初始化 HiddifyOptions
|
||
// 否则 libcore 的 StartService 会因为 HiddifyOptions == nil 而 panic
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ▶️ 步骤1: 准备 changeOptions...');
|
||
KRLogUtil.kr_i('📡 初始化 HiddifyOptions...', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_startInternal] 开始 changeOptions - ${DateTime.now()}');
|
||
await _kr_preflightWindowsCommandServerPort();
|
||
final configMap = _getConfigOption();
|
||
final oOption = SingboxConfigOption.fromJson(configMap);
|
||
|
||
// ✅ 调试日志:确认 setSystemProxy 值正确传递给 libcore.dll
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] 📦 set-system-proxy: ${configMap["set-system-proxy"]}');
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] 📦 enable-tun: ${configMap["enable-tun"]}');
|
||
KRLogUtil.kr_i('📡 HiddifyOptions 关键配置:', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - set-system-proxy: ${configMap["set-system-proxy"]}', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - enable-tun: ${configMap["enable-tun"]}', tag: 'SingBox');
|
||
KRLogUtil.kr_i(' - mixed-port: ${configMap["mixed-port"]}', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_startInternal] HiddifyOptions: set-system-proxy=${configMap["set-system-proxy"]}, enable-tun=${configMap["enable-tun"]}, mixed-port=${configMap["mixed-port"]}');
|
||
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ⏳ 步骤2: 调用 changeOptions (libcore.dll)...');
|
||
final changeOptionsStartTime = DateTime.now();
|
||
final changeResult = await kr_singBox.changeOptions(oOption).run();
|
||
final changeOptionsDuration = DateTime.now().difference(changeOptionsStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ✅ 步骤2完成: changeOptions 耗时 ${changeOptionsDuration}ms');
|
||
await KRFileLogger.log('[_kr_startInternal] changeOptions 完成 - ${DateTime.now()}');
|
||
changeResult.match(
|
||
(error) {
|
||
KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ❌ changeOptions 失败: $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 KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ⏳ 步骤3: 调用 start (libcore.dll)...');
|
||
final startStartTime = DateTime.now();
|
||
await KRFileLogger.log('[_kr_startInternal] 🔴 开始 kr_singBox.start() - $startStartTime');
|
||
await kr_singBox.start(_cutPath, kr_configName, false).map(
|
||
(r) {
|
||
KRLogUtil.kr_i('✅ SingBox 启动成功', tag: 'SingBox');
|
||
},
|
||
).mapLeft((err) {
|
||
KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ❌ start 失败: $err');
|
||
KRLogUtil.kr_e('❌ SingBox 启动失败: $err', tag: 'SingBox');
|
||
// 不需要手动设置状态,libcore 会通过 status stream 自动发送 Stopped 事件
|
||
throw err;
|
||
}).run();
|
||
|
||
final startEndTime = DateTime.now();
|
||
final startDurationMs = startEndTime.difference(startStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ✅ 步骤3完成: start 耗时 ${startDurationMs}ms');
|
||
await KRFileLogger.log('[_kr_startInternal] ✅ kr_singBox.start() 完成(耗时: ${startDurationMs}ms)- $startEndTime');
|
||
|
||
// 🔍 关键诊断:检查 8964 端口是否真的准备好了!
|
||
await KRFileLogger.log('[_kr_startInternal] 🔍 检查 sing-box 的 8964 端口是否真的准备好...');
|
||
bool port8964Ready = false;
|
||
int connectionAttempts = 0;
|
||
final portCheckStartTime = DateTime.now();
|
||
|
||
// 立即尝试连接,看 sing-box 是否真的启动了
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 500),
|
||
onTimeout: () => throw Exception('8964 端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
port8964Ready = true;
|
||
connectionAttempts = 0;
|
||
await KRFileLogger.log('[_kr_startInternal] ✅ 8964 端口已准备好(立即连接成功)');
|
||
} catch (e) {
|
||
// 立即失败,说明 sing-box 还没准备好
|
||
connectionAttempts = 1;
|
||
final portCheckDuration = DateTime.now().difference(portCheckStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[_kr_startInternal] ❌ 【关键发现】8964 端口未准备好!(连接失败:$e)');
|
||
await KRFileLogger.log('[_kr_startInternal] 🚨 这说明 start() 返回成功,但 sing-box 还在初始化中!');
|
||
await KRFileLogger.log('[_kr_startInternal] 🚨 后台任务在 1000ms 后会尝试订阅,极可能失败(这是 UI 卡顿的根本原因!)');
|
||
}
|
||
|
||
// ✅ 系统代理由 Go 后端 (libcore.dll) 自动处理
|
||
// 原因:通过 changeOptions() 传递 setSystemProxy: true,libcore 会自动设置系统代理
|
||
// 参考 Hiddify 实现:Dart 层不再手动调用注册表或 WinINet API
|
||
await KRFileLogger.log('[_kr_startInternal] ℹ️ 系统代理由 libcore 自动管理 - ${DateTime.now()}');
|
||
|
||
// 🍎 macOS 专用:额外设置 SOCKS5 系统代理(让 Telegram 等应用自动走代理)
|
||
// 原因:libcore 只设置 HTTP/HTTPS 代理,但 Telegram 只检测 SOCKS5 代理
|
||
// 参考 Clash:同时设置 HTTP + SOCKS5 系统代理
|
||
if (Platform.isMacOS && kr_connectionType.value != KRConnectionType.global) {
|
||
await _kr_setMacOSSocks5Proxy(true);
|
||
}
|
||
|
||
// 🔴 【方案 A】启动 sing-box 进程健康监测
|
||
// 目的:检测 sing-box 是否在启动后 1-2 秒内崩溃
|
||
// 背景:日志显示 start() 返回成功,但 1.4 秒后 8964 端口无法连接
|
||
// 说明 sing-box 进程启动后崩溃了,导致后续 UI 卡顿
|
||
unawaited(_monitorSingBoxProcess());
|
||
|
||
// ⚠️ 关键修复:在启动成功后立即订阅统计流
|
||
// 原因:
|
||
// 1. 统计流需要主动订阅才能接收数据
|
||
// 2. UI 只是读取 kr_stats.value,不会触发订阅
|
||
// 3. command.sock 在 start() 成功后会立即创建
|
||
|
||
KRLogUtil.kr_i('✅ SingBox 核心已启动,开始初始化 command client', tag: 'SingBox');
|
||
|
||
// 🔑 在后台延迟订阅统计流和分组流,避免阻塞 UI
|
||
await KRFileLogger.log('[_kr_startInternal] 🟢 关键路径完成,返回给调用者 - ${DateTime.now()}');
|
||
Future.delayed(const Duration(milliseconds: 1000), () async {
|
||
final backgroundStartTime = DateTime.now();
|
||
|
||
// 【关键】检查停止标志,如果用户在延迟期间点击了停止,就立即返回
|
||
// 这避免了竞态条件:后台任务在 stop() 过程中仍然在执行订阅
|
||
if (_stopRequested) {
|
||
await KRFileLogger.log('[后台任务] ⏭️ 检测到停止标志,后台任务立即返回(避免竞态条件) - $backgroundStartTime');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await KRFileLogger.log('[后台任务] 🔴 启动 1000ms 延迟后的后台任务 - $backgroundStartTime');
|
||
|
||
// 订阅统计流
|
||
try {
|
||
// 🔍 诊断:检查 8964 端口状态
|
||
final subscribeCheckStartTime = DateTime.now();
|
||
bool port8964ConnectOk = false;
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 200),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
port8964ConnectOk = true;
|
||
await KRFileLogger.log('[后台任务] 📊 准备订阅前检查:8964 端口✅可以连接');
|
||
} catch (portE) {
|
||
await KRFileLogger.log('[后台任务] 📊 准备订阅前检查:8964 端口❌无法连接($portE)- 这说明 sing-box 还没准备好!');
|
||
}
|
||
|
||
// 🔧 关键修复:如果端口检查失败,立即返回,避免 FFI 调用阻塞 UI 3+ 秒
|
||
if (!port8964ConnectOk) {
|
||
await KRFileLogger.log('[后台任务] ⛔ 8964 端口不可用,跳过订阅操作(避免 FFI 超时阻塞 UI)- ${DateTime.now()}');
|
||
KRLogUtil.kr_w('⚠️ sing-box command 端口不可用,跳过订阅', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
// 【关键】在执行订阅前再次检查停止标志
|
||
if (_stopRequested) {
|
||
await KRFileLogger.log('[后台任务] ⏭️ 停止标志已设置,取消统计订阅 - ${DateTime.now()}');
|
||
return;
|
||
}
|
||
|
||
await KRFileLogger.log('[后台任务] 🟡 开始订阅统计数据流 - ${DateTime.now()}');
|
||
KRLogUtil.kr_i('📊 开始订阅统计数据流...', tag: 'SingBox');
|
||
await _kr_subscribeToStats(); // 🔧 UI阻塞修复:使用await确保让出控制权
|
||
KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ✅ 统计数据流订阅成功 - ${DateTime.now()}');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 统计数据流订阅失败(稍后重试): $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ❌ 统计数据流订阅失败: $e - ${DateTime.now()}');
|
||
await KRFileLogger.log('[后台任务] 🚨 这是导致后续操作失败的关键问题!后台任务被阻塞,导致 Lock 被占用!');
|
||
// 如果第一次失败,再等待一段时间重试
|
||
Future.delayed(const Duration(milliseconds: 2000), () async {
|
||
// 🔧 重试前检查端口可用性,避免 FFI 阻塞
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 200),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
} catch (_) {
|
||
KRLogUtil.kr_w('⚠️ 重试订阅统计流时端口不可用,跳过', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _kr_subscribeToStats(); // 🔧 UI阻塞修复:使用await
|
||
KRLogUtil.kr_i('✅ 统计数据流重试订阅成功', tag: 'SingBox');
|
||
} catch (e2) {
|
||
KRLogUtil.kr_e('❌ 统计数据流重试订阅失败: $e2', tag: 'SingBox');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 🔧 关键修复:订阅分组数据流
|
||
try {
|
||
// 【关键】在执行订阅前再次检查停止标志
|
||
if (_stopRequested) {
|
||
await KRFileLogger.log('[后台任务] ⏭️ 停止标志已设置,取消分组订阅 - ${DateTime.now()}');
|
||
return;
|
||
}
|
||
|
||
await KRFileLogger.log('[后台任务] 🟡 开始订阅分组数据流 - ${DateTime.now()}');
|
||
KRLogUtil.kr_i('📋 开始订阅分组数据流...', tag: 'SingBox');
|
||
await _kr_subscribeToGroups(); // 🔧 UI阻塞修复:使用await确保让出控制权
|
||
KRLogUtil.kr_i('✅ 分组数据流订阅成功', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ✅ 分组数据流订阅成功 - ${DateTime.now()}');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 分组数据流订阅失败(稍后重试): $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ❌ 分组数据流订阅失败: $e - ${DateTime.now()}');
|
||
// 如果第一次失败,再等待一段时间重试
|
||
Future.delayed(const Duration(milliseconds: 2000), () async {
|
||
// 🔧 重试前检查端口可用性,避免 FFI 阻塞
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 200),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
} catch (_) {
|
||
KRLogUtil.kr_w('⚠️ 重试订阅分组流时端口不可用,跳过', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await _kr_subscribeToGroups(); // 🔧 UI阻塞修复:使用await
|
||
KRLogUtil.kr_i('✅ 分组数据流重试订阅成功', tag: 'SingBox');
|
||
} catch (e2) {
|
||
KRLogUtil.kr_e('❌ 分组数据流重试订阅失败: $e2', tag: 'SingBox');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 🔧 关键修复:恢复用户选择的节点
|
||
try {
|
||
// 【关键】在执行节点恢复前再次检查停止标志
|
||
if (_stopRequested) {
|
||
await KRFileLogger.log('[后台任务] ⏭️ 停止标志已设置,取消节点恢复 - ${DateTime.now()}');
|
||
return;
|
||
}
|
||
|
||
final nodeRestoreStartTime = DateTime.now();
|
||
await KRFileLogger.log('[后台任务] 🟡 开始恢复用户选择的节点 - $nodeRestoreStartTime');
|
||
final selectedNode = await KRSecureStorage().kr_readData(key: _keySelectedNode);
|
||
if (selectedNode != null && selectedNode.isNotEmpty && selectedNode != 'auto') {
|
||
KRLogUtil.kr_i('🎯 恢复用户选择的节点: $selectedNode', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] 🟡 找到保存的节点: $selectedNode - ${DateTime.now()}');
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 启动后恢复节点选择: $selectedNode');
|
||
}
|
||
|
||
// 🔧 关键修复:等待活动组准备就绪,而不是固定延迟
|
||
// 这是解决"连接后 selected=auto"问题的关键
|
||
KRLogUtil.kr_i('⏳ 等待活动组准备就绪...', tag: 'SingBox');
|
||
final groupWaitStartTime = DateTime.now();
|
||
await KRFileLogger.log('[后台任务] ⏳ 开始等待活动组准备就绪 - $groupWaitStartTime');
|
||
int waitCount = 0;
|
||
const maxWaitCount = 25; // 最多等待 5 秒 (25 * 200ms)
|
||
while (kr_activeGroups.isEmpty && waitCount < maxWaitCount) {
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
waitCount++;
|
||
if (waitCount % 5 == 0) {
|
||
KRLogUtil.kr_d('⏳ 等待活动组... ($waitCount/$maxWaitCount)', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ⏳ 等待活动组进行中... (${waitCount}/$maxWaitCount, 已耗时 ${DateTime.now().difference(groupWaitStartTime).inMilliseconds}ms) - ${DateTime.now()}');
|
||
}
|
||
}
|
||
|
||
if (kr_activeGroups.isEmpty) {
|
||
KRLogUtil.kr_w('⚠️ 等待活动组超时,仍尝试恢复节点', tag: 'SingBox');
|
||
final groupWaitDuration = DateTime.now().difference(groupWaitStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[后台任务] ⚠️ 等待活动组超时 (${groupWaitDuration}ms) - ${DateTime.now()}');
|
||
} else {
|
||
KRLogUtil.kr_i('✅ 活动组已就绪,数量: ${kr_activeGroups.length}', tag: 'SingBox');
|
||
final groupWaitDuration = DateTime.now().difference(groupWaitStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[后台任务] ✅ 活动组已就绪,数量: ${kr_activeGroups.length} (耗时 ${groupWaitDuration}ms) - ${DateTime.now()}');
|
||
}
|
||
|
||
// 🔧 关键修复:使用 await 等待节点切换完成
|
||
try {
|
||
final switchStartTime = DateTime.now();
|
||
await KRFileLogger.log('[后台任务] 🟡 开始恢复节点切换: $selectedNode - $switchStartTime');
|
||
await kr_selectOutbound(selectedNode);
|
||
KRLogUtil.kr_i('✅ 节点已切换到用户选择: $selectedNode', tag: 'SingBox');
|
||
final switchDuration = DateTime.now().difference(switchStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[后台任务] ✅ 节点切换成功: $selectedNode (耗时 ${switchDuration}ms) - ${DateTime.now()}');
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 节点切换成功: $selectedNode');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 节点切换失败: $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ❌ 节点切换失败: $e - ${DateTime.now()}');
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 节点切换失败: $e');
|
||
}
|
||
}
|
||
|
||
final nodeRestoreDuration = DateTime.now().difference(nodeRestoreStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[后台任务] ✅ 节点恢复流程完成(总耗时 ${nodeRestoreDuration}ms)- ${DateTime.now()}');
|
||
} else {
|
||
KRLogUtil.kr_i('ℹ️ 没有保存的节点选择或为auto,使用默认配置', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ℹ️ 没有保存的节点选择或为auto - ${DateTime.now()}');
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 没有保存的节点选择,使用默认');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 恢复节点选择失败: $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] ❌ 恢复节点选择失败: $e - ${DateTime.now()}');
|
||
if (kDebugMode) {
|
||
KRFileLogger.log('[黑屏调试] 🔵 恢复节点选择失败: $e');
|
||
}
|
||
}
|
||
|
||
final backgroundDuration = DateTime.now().difference(backgroundStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[后台任务] 🟢 所有后台任务完成(总耗时 ${backgroundDuration}ms)- ${DateTime.now()}');
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('💥 后台任务异常: $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[后台任务] 💥 后台任务异常: $e - ${DateTime.now()}');
|
||
}
|
||
});
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('💥 SingBox 启动异常: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox');
|
||
// 不需要手动设置状态,libcore 会自动处理
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
/// 停止服务
|
||
Future<void> kr_stop() async {
|
||
// 🔧 文件日志:记录 kr_stop() 调用 + 时间戳
|
||
final clickTime = DateTime.now();
|
||
await KRFileLogger.log('[kr_stop] ⏱️ 用户点击关闭 VPN - $clickTime');
|
||
|
||
// 🔧 文件日志:等待 Lock + 时间戳
|
||
final beforeLock = DateTime.now();
|
||
await KRFileLogger.log('[kr_stop] ⏱️ 开始等待 Lock... - $beforeLock');
|
||
|
||
// ❌ 关键修复:添加 Lock 保护,防止 kr_start() 和 kr_stop() 并发执行
|
||
// 快速连续点击开关时,此 Lock 会序列化所有操作,防止 UI 堵塞
|
||
return _startStopLock.synchronized(() async {
|
||
final afterLock = DateTime.now();
|
||
final lockWaitMs = afterLock.difference(beforeLock).inMilliseconds;
|
||
await KRFileLogger.log('[kr_stop] ✅ Lock 已获取(等待 ${lockWaitMs}ms)- $afterLock');
|
||
|
||
// 【关键】设置停止标志,通知后台任务停止执行
|
||
// 这会让正在执行或即将执行的后台任务立即返回,避免竞态条件
|
||
_stopRequested = true;
|
||
await KRFileLogger.log('[kr_stop] 🔴 设置停止标志,通知后台任务立即停止执行 - ${DateTime.now()}');
|
||
|
||
try {
|
||
final beforeInternal = DateTime.now();
|
||
await _kr_stopInternal();
|
||
final afterInternal = DateTime.now();
|
||
final internalMs = afterInternal.difference(beforeInternal).inMilliseconds;
|
||
await KRFileLogger.log('[kr_stop] ✅ _kr_stopInternal() 完成(耗时 ${internalMs}ms)- $afterInternal');
|
||
} catch (e) {
|
||
await KRFileLogger.log('[kr_stop] ❌ _kr_stopInternal() 异常: $e - ${DateTime.now()}');
|
||
rethrow;
|
||
} finally {
|
||
// 在返回之前重置标志,为下一次启动做准备
|
||
_stopRequested = false;
|
||
await KRFileLogger.log('[kr_stop] 🟢 停止标志已重置,为下一次启动做准备 - ${DateTime.now()}');
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 实际的停止逻辑(被 Lock 保护)
|
||
Future<void> _kr_stopInternal() async {
|
||
final stopInternalStartTime = DateTime.now();
|
||
await KRFileLogger.log('[_kr_stopInternal] 🔴 _kr_stopInternal 方法开始执行 - $stopInternalStartTime');
|
||
|
||
// ✅ DNS 由 libcore.dll 自动管理
|
||
try {
|
||
KRLogUtil.kr_i('🛑 停止 SingBox 服务...', tag: 'SingBox');
|
||
|
||
// 取消节点选择监控定时器
|
||
_nodeSelectionTimer?.cancel();
|
||
_nodeSelectionTimer = null;
|
||
KRLogUtil.kr_i('✅ 节点选择监控已停止', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] ✅ 节点选择监控已停止 - ${DateTime.now()}');
|
||
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// 添加超时保护,防止 stop() 调用阻塞
|
||
// 🔧 超时时间 10 秒,确保 libcore 完成清理
|
||
try {
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ⏳ 步骤: 调用 stop (libcore.dll)...');
|
||
final stopStartTime = DateTime.now();
|
||
await KRFileLogger.log('[_kr_stopInternal] 开始 kr_singBox.stop() - ${DateTime.now()}');
|
||
await kr_singBox.stop().run().timeout(
|
||
const Duration(seconds: 10), // ← 从 3 秒延长到 10 秒
|
||
onTimeout: () {
|
||
KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ⚠️ stop 超时(10秒)');
|
||
KRLogUtil.kr_w('⚠️ 停止操作超时(10秒),强制继续', tag: 'SingBox');
|
||
return const Left('timeout');
|
||
},
|
||
);
|
||
final stopDuration = DateTime.now().difference(stopStartTime).inMilliseconds;
|
||
await KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ✅ stop 完成: 耗时 ${stopDuration}ms');
|
||
await KRFileLogger.log('[_kr_stopInternal] kr_singBox.stop() 完成 - ${DateTime.now()}');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 停止操作失败(可能已经停止): $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] kr_singBox.stop() 异常: $e - ${DateTime.now()}');
|
||
// 继续执行清理操作
|
||
}
|
||
|
||
// ✅ DNS 和系统代理都由 Go 后端 (libcore.dll) 自动管理
|
||
// 参考 Hiddify 实现:sing-box 内部有完整的 DNS 代理机制,不需要手动恢复系统 DNS
|
||
await KRFileLogger.log('[_kr_stopInternal] ℹ️ DNS 由 libcore 自动管理 - ${DateTime.now()}');
|
||
|
||
// 🍎 macOS 专用:清除 SOCKS5 系统代理
|
||
if (Platform.isMacOS) {
|
||
await _kr_setMacOSSocks5Proxy(false);
|
||
}
|
||
|
||
// 🔧 P0-1: 明确清理 groups 和 stats 订阅
|
||
final subscriptionCleanStartTime = DateTime.now();
|
||
await KRFileLogger.log('[_kr_stopInternal] 🔴 开始清理订阅 - $subscriptionCleanStartTime');
|
||
KRLogUtil.kr_i('🧹 清理 command client 订阅...', tag: 'SingBox');
|
||
|
||
// 清理 groups 订阅
|
||
try {
|
||
await KRFileLogger.log('[_kr_stopInternal] 🟡 清理Groups订阅中 - ${DateTime.now()}');
|
||
_subscriptionMap[_SubscriptionType.groups]?.cancel();
|
||
_subscriptionMap.remove(_SubscriptionType.groups);
|
||
KRLogUtil.kr_i('✅ Groups 订阅已取消', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] ✅ Groups 订阅已取消 - ${DateTime.now()}');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ Groups 订阅取消失败: $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] ❌ Groups 订阅取消失败: $e - ${DateTime.now()}');
|
||
}
|
||
|
||
// 清理 stats 订阅
|
||
try {
|
||
await KRFileLogger.log('[_kr_stopInternal] 🟡 清理Stats订阅中 - ${DateTime.now()}');
|
||
_subscriptionMap[_SubscriptionType.stats]?.cancel();
|
||
_subscriptionMap.remove(_SubscriptionType.stats);
|
||
KRLogUtil.kr_i('✅ Stats 订阅已取消', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] ✅ Stats 订阅已取消 - ${DateTime.now()}');
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ Stats 订阅取消失败: $e', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] ❌ Stats 订阅取消失败: $e - ${DateTime.now()}');
|
||
}
|
||
|
||
// 从列表中清除非 Status 项(向后兼容)
|
||
await KRFileLogger.log('[_kr_stopInternal] 🟡 清理其他订阅中 - ${DateTime.now()}');
|
||
final subscriptionsToCancel = _kr_subscriptions.where((sub) {
|
||
final hashStr = sub.hashCode.toString();
|
||
return !hashStr.contains('Status'); // 不取消状态订阅
|
||
}).toList();
|
||
|
||
for (var subscription in subscriptionsToCancel) {
|
||
_kr_subscriptions.remove(subscription);
|
||
}
|
||
|
||
final subscriptionCleanDuration = DateTime.now().difference(subscriptionCleanStartTime).inMilliseconds;
|
||
KRLogUtil.kr_i('✅ 订阅清理完成,剩余订阅数: ${_kr_subscriptions.length}', tag: 'SingBox');
|
||
await KRFileLogger.log('[_kr_stopInternal] ✅ 订阅清理完成(耗时: ${subscriptionCleanDuration}ms),剩余订阅数: ${_kr_subscriptions.length} - ${DateTime.now()}');
|
||
|
||
// 不手动设置状态,由 libcore 通过 status stream 自动发送 Stopped 事件
|
||
KRLogUtil.kr_i('✅ SingBox 停止请求已发送', tag: 'SingBox');
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('停止服务时出错: $e');
|
||
KRLogUtil.kr_e('错误堆栈: $stackTrace');
|
||
|
||
// ✅ DNS 由 libcore 自动管理,无需手动恢复
|
||
|
||
// 不手动设置状态,信任 libcore 的状态管理
|
||
rethrow;
|
||
} finally {
|
||
// ✅ 关键修复:系统代理恢复改为异步后台执行,不在 finally 块中 await
|
||
// 这样 kr_stop() 可以立即返回,不被注册表操作阻塞
|
||
final finallyStartTime = DateTime.now();
|
||
await KRFileLogger.log('[_kr_stopInternal] 🔴 finally 块开始执行 - $finallyStartTime');
|
||
|
||
// ✅ 系统代理恢复由 Go 后端 (libcore.dll) 自动处理
|
||
// 原因:libcore.stop() 会自动恢复系统代理设置
|
||
// 参考 Hiddify 实现:Dart 层不再手动调用注册表或 WinINet API
|
||
await KRFileLogger.log('[_kr_stopInternal] ℹ️ 系统代理恢复由 libcore 自动管理 - ${DateTime.now()}');
|
||
|
||
// 最后的诊断日志
|
||
final stopInternalEndTime = DateTime.now();
|
||
await KRFileLogger.log('[_kr_stopInternal] 🟢 _kr_stopInternal 方法即将退出 - $stopInternalEndTime');
|
||
}
|
||
}
|
||
|
||
/// 🪟 Windows:Start 之前检查 old command server 端口占用情况
|
||
///
|
||
/// ✅ 极限修复:完全跳过 Windows 端口检查!
|
||
/// 原因:
|
||
/// 1. netstat、powershell、taskkill 都是系统命令,每个 0.5-1 秒
|
||
/// 2. 连续调用导致 2-3 秒的累计卡顿
|
||
/// 3. 造成 icon 停止转动、整个框架卡死
|
||
/// 4. libcore 已经有完整的端口冲突处理逻辑
|
||
///
|
||
/// 相信 libcore 的自动重试机制,比强制同步检查更可靠!
|
||
Future<void> _kr_preflightWindowsCommandServerPort() async {
|
||
if (!Platform.isWindows) return;
|
||
|
||
KRLogUtil.kr_i('⏭️ 跳过 Windows 端口预检查,相信 libcore 的自动处理机制', tag: 'SingBox');
|
||
// 完全不做检查,直接返回
|
||
// libcore 会自动处理所有的端口冲突情况
|
||
}
|
||
|
||
// ✅ 系统代理设置/恢复已移交给 Go 后端 (libcore.dll) 自动处理
|
||
// 参考 Hiddify 实现:通过 changeOptions() 传递 setSystemProxy: true
|
||
// libcore 在 start() 时自动设置代理,在 stop() 时自动恢复
|
||
// 优势:
|
||
// ✅ 无黑窗问题(Go 后端内部处理,不执行外部命令)
|
||
// ✅ 更快速(无进程启动开销)
|
||
// ✅ 更可靠(由 sing-box 核心统一管理)
|
||
|
||
/// Windows: cleanup fixed command server port on logout.
|
||
/// Returns a user-facing warning message if another process owns the port.
|
||
Future<String?> kr_cleanupWindowsCommandServerPortOnLogout() async {
|
||
if (!Platform.isWindows) return null;
|
||
|
||
try {
|
||
final pidInUse = await _kr_getListeningPidByPort(_krWindowsCommandServerPort);
|
||
if (pidInUse == null) return null;
|
||
|
||
final processName = await _kr_getProcessNameByPid(pidInUse);
|
||
final currentExeName = p.basename(Platform.resolvedExecutable).toLowerCase();
|
||
final normalizedProcessName = processName?.toLowerCase();
|
||
|
||
if (normalizedProcessName != null && normalizedProcessName == currentExeName) {
|
||
KRLogUtil.kr_w(
|
||
'logout cleanup: detected port $_krWindowsCommandServerPort in use by self (pid=$pidInUse), killing',
|
||
tag: 'SingBox',
|
||
);
|
||
final killed = await _kr_killProcess(pidInUse);
|
||
if (!killed) {
|
||
KRLogUtil.kr_w(
|
||
'logout cleanup: failed to kill process (pid=$pidInUse)',
|
||
tag: 'SingBox',
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
final displayName = processName ?? 'unknown';
|
||
return '当前有其他进程占用$_krWindowsCommandServerPort端口,进程名: $displayName,Pid: $pidInUse,请手动关闭';
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('logout cleanup: port check failed: $e', tag: 'SingBox');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Windows: get the listening PID for a port (LISTEN only).
|
||
Future<int?> _kr_getListeningPidByPort(int port) async {
|
||
if (!Platform.isWindows) return null;
|
||
|
||
// Use numeric output to avoid reverse DNS lookups, which can stall when DNS is unstable.
|
||
final result = await KRWindowsProcessUtil.runHidden('netstat', ['-ano', '-p', 'tcp', '-n']);
|
||
if (result.exitCode != 0) {
|
||
KRLogUtil.kr_w('⚠️ netstat 执行失败: ${result.stderr}', tag: 'SingBox');
|
||
return null;
|
||
}
|
||
|
||
final output = result.stdout.toString();
|
||
final lines = output.split(RegExp(r'\r?\n'));
|
||
for (final rawLine in lines) {
|
||
final line = rawLine.trim();
|
||
if (line.isEmpty) continue;
|
||
if (!line.startsWith('TCP')) continue;
|
||
|
||
final parts = line.split(RegExp(r'\s+'));
|
||
// 预期格式: TCP <local> <foreign> <state> <pid>
|
||
if (parts.length < 4) continue;
|
||
|
||
final local = parts.length >= 2 ? parts[1] : '';
|
||
final foreign = parts.length >= 3 ? parts[2] : '';
|
||
final pidStr = parts.isNotEmpty ? parts.last : '';
|
||
|
||
// 🔧 P2-1: 改进端口检测精度 - 使用更精确的正则匹配
|
||
// 监听行 foreign 一般为 0.0.0.0:0 或 [::]:0(避免依赖 LISTENING 文本是否本地化)
|
||
final isListeningRow = RegExp(r'(0\.0\.0\.0|::|.*?):\s*0\s*$').hasMatch(foreign);
|
||
if (!isListeningRow) continue;
|
||
|
||
// 精确匹配端口号(不使用字符串 endsWith,避免误匹配)
|
||
// 例如: 127.0.0.1:51213 或 [::1]:51213 或 *:51213
|
||
final portPattern = RegExp(r':\s*$port\s*$');
|
||
if (!portPattern.hasMatch(local)) continue;
|
||
|
||
final pidValue = int.tryParse(pidStr.trim());
|
||
if (pidValue != null && pidValue > 0) {
|
||
KRLogUtil.kr_d(
|
||
'🔍 端口 $port 被进程 $pidValue 占用 (local: $local, foreign: $foreign)',
|
||
tag: 'SingBox',
|
||
);
|
||
return pidValue;
|
||
}
|
||
}
|
||
|
||
KRLogUtil.kr_d('✅ 端口 $port 未被占用', tag: 'SingBox');
|
||
return null;
|
||
}
|
||
|
||
Future<String?> _kr_getProcessNameByPid(int pidValue) async {
|
||
if (!Platform.isWindows) return null;
|
||
|
||
try {
|
||
final result = await KRWindowsProcessUtil.runHidden('tasklist', [
|
||
'/FI',
|
||
'PID eq $pidValue',
|
||
'/FO',
|
||
'CSV',
|
||
'/NH',
|
||
]);
|
||
if (result.exitCode != 0) {
|
||
KRLogUtil.kr_w('⚠️ tasklist 执行失败: ${result.stderr}', tag: 'SingBox');
|
||
return null;
|
||
}
|
||
|
||
final stdout = result.stdout.toString().trim();
|
||
if (stdout.isEmpty) return null;
|
||
if (stdout.startsWith('INFO:')) return null;
|
||
|
||
// CSV 第一列为 Image Name,例如: "BearVPN.exe","1234",...
|
||
final firstLine = stdout.split(RegExp(r'\r?\n')).first.trim();
|
||
final match = RegExp(r'^"([^"]+)"').firstMatch(firstLine);
|
||
return match?.group(1);
|
||
} catch (e) {
|
||
// 🔧 P2-8: 读/写竞争条件 - 捕获异常,防止进程查询导致的崩溃
|
||
KRLogUtil.kr_w('⚠️ 进程信息查询异常: $e', tag: 'SingBox');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
Future<bool> _kr_killProcess(int pidValue) async {
|
||
if (!Platform.isWindows) return false;
|
||
|
||
final result = await KRWindowsProcessUtil.runHidden('taskkill', [
|
||
'/PID',
|
||
pidValue.toString(),
|
||
'/T',
|
||
'/F',
|
||
]);
|
||
if (result.exitCode == 0) return true;
|
||
|
||
KRLogUtil.kr_w(
|
||
'⚠️ taskkill 失败: exitCode=${result.exitCode}, stderr=${result.stderr}, stdout=${result.stdout}',
|
||
tag: 'SingBox',
|
||
);
|
||
return false;
|
||
}
|
||
|
||
Future<bool> _kr_waitPortFree(int port, {required Duration timeout}) async {
|
||
final deadline = DateTime.now().add(timeout);
|
||
while (DateTime.now().isBefore(deadline)) {
|
||
final pidInUse = await _kr_getListeningPidByPort(port);
|
||
if (pidInUse == null) return true;
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
return false;
|
||
}
|
||
|
||
///
|
||
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');
|
||
|
||
// 🔧 P0-2: 改进异常处理 - stop 失败也要尝试 start
|
||
// 🔧 P1-1: 优化网络抖动 - 监听状态变化而非固定延迟
|
||
// ✅ DNS/代理 由 libcore.dll 自动管理
|
||
try {
|
||
// 1. 先停止
|
||
try {
|
||
await kr_stop();
|
||
} catch (stopError) {
|
||
KRLogUtil.kr_e('❌ 停止失败: $stopError,仍尝试启动', tag: 'SingBox');
|
||
// 不 rethrow,继续尝试启动
|
||
}
|
||
|
||
// 🔧 P1-1: 2. 等待完全停止 - 监听状态而非固定延迟(减少网络中断时间)
|
||
try {
|
||
// 如果已经是停止状态,直接继续
|
||
if (kr_status.value is SingboxStopped) {
|
||
KRLogUtil.kr_i('✅ SingBox 已是停止状态,立即启动', tag: 'SingBox');
|
||
} else {
|
||
// 等待状态变为停止,最多等待2秒(比原来的盲等500ms更聪明)
|
||
final completer = Completer<void>();
|
||
late final Worker worker;
|
||
|
||
worker = ever(kr_status, (status) {
|
||
if (status is SingboxStopped) {
|
||
if (!completer.isCompleted) {
|
||
completer.complete();
|
||
worker.dispose();
|
||
KRLogUtil.kr_i('✅ 检测到 SingBox 已停止,立即启动新实例', tag: 'SingBox');
|
||
}
|
||
}
|
||
});
|
||
|
||
await completer.future.timeout(
|
||
const Duration(seconds: 2),
|
||
onTimeout: () {
|
||
KRLogUtil.kr_w('⏱️ 等待停止状态超时(2s),使用备用延迟', tag: 'SingBox');
|
||
worker.dispose();
|
||
},
|
||
);
|
||
}
|
||
} catch (stateError) {
|
||
KRLogUtil.kr_w('⚠️ 状态监听异常: $stateError,使用备用延迟', tag: 'SingBox');
|
||
// 备用:使用较短的延迟(300ms代替原来的500ms)
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
}
|
||
|
||
// 🔧 P1-1: 3. 重新启动
|
||
try {
|
||
await kr_start();
|
||
KRLogUtil.kr_i('✅ SingBox 重启完成', tag: 'SingBox');
|
||
} catch (startError) {
|
||
KRLogUtil.kr_e('❌ 重启失败: $startError', tag: 'SingBox');
|
||
rethrow; // 只有启动失败才抛异常
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 重启异常: $e', tag: 'SingBox');
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
//// 设置出站模式
|
||
Future<void> kr_updateConnectionType(KRConnectionType newType) async {
|
||
final previousType = kr_connectionType.value; // 保存原模式用于失败时回滚
|
||
|
||
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;
|
||
}
|
||
|
||
// ✅ 改进:直接尝试切换(不做权限检查避免黑屏)
|
||
// 关键改进:保存原模式,失败时回滚以防止自动重连机制无限循环
|
||
KRLogUtil.kr_i('📝 尝试切换模式到: $newType(原模式: $previousType)', tag: 'SingBox');
|
||
|
||
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');
|
||
try {
|
||
// 重启前添加超时保护(10秒),避免卡顿
|
||
await kr_restart().timeout(
|
||
const Duration(seconds: 10),
|
||
onTimeout: () {
|
||
KRLogUtil.kr_w('⏱️ VPN重启超时(10s),可能缺少权限', tag: 'SingBox');
|
||
throw TimeoutException('VPN重启超时,请检查是否需要管理员权限');
|
||
},
|
||
);
|
||
KRLogUtil.kr_i('✅ VPN重启完成', tag: 'SingBox');
|
||
} catch (restartError) {
|
||
KRLogUtil.kr_e('❌ VPN重启失败: $restartError', tag: 'SingBox');
|
||
|
||
// 重启失败时回滚模式改变,避免触发自动重连机制无限循环
|
||
KRLogUtil.kr_w('🔙 重启失败,回滚模式到: $previousType', tag: 'SingBox');
|
||
kr_connectionType.value = previousType;
|
||
|
||
// ❌ 关键修复:不要 rethrow,异常已经完整处理(已回滚 + Toast 提示由监听器负责)
|
||
// rethrow;
|
||
}
|
||
} else {
|
||
KRLogUtil.kr_i('ℹ️ VPN未启动,配置已更新', tag: 'SingBox');
|
||
}
|
||
} catch (e, stackTrace) {
|
||
KRLogUtil.kr_e('💥 更新连接类型异常: $e', tag: 'SingBox');
|
||
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox');
|
||
|
||
// 最终保险:异常时确保回滚模式
|
||
if (kr_connectionType.value != previousType) {
|
||
KRLogUtil.kr_w('🔙 异常捕获,回滚模式到: $previousType', tag: 'SingBox');
|
||
kr_connectionType.value = previousType;
|
||
}
|
||
|
||
// ❌ 关键修复:不要 rethrow
|
||
// 异常已经通过 Toast 由监听器提示,模式已回滚
|
||
// 继续 rethrow 会导致对话框无法关闭,让应用看起来"卡"了
|
||
// 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 {
|
||
// ========== 关键路径:必需的同步操作 ==========
|
||
// 🔧 P2新: 检查 VPN 是否在运行
|
||
if (kr_status.value is! SingboxStarted) {
|
||
KRLogUtil.kr_w('⚠️ VPN 未运行,无法选择节点: $tag', tag: 'SingBox');
|
||
throw Exception('VPN 未启动,无法选择节点。请先启动 VPN');
|
||
}
|
||
|
||
KRLogUtil.kr_i('🎯 开始选择出站节点: $tag', tag: 'SingBox');
|
||
|
||
// 🔧 关键修复:使用 await 确保保存完成
|
||
try {
|
||
await KRSecureStorage().kr_saveData(key: _keySelectedNode, value: tag);
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('✅ 节点选择已保存: $tag', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'SingBox');
|
||
}
|
||
|
||
// 🔧 关键修复:使用 await 确保 command client 初始化完成
|
||
try {
|
||
await _kr_ensureCommandClientInitialized();
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('✅ Command client 已就绪,执行节点切换', tag: 'SingBox');
|
||
}
|
||
|
||
// 🔧 关键修复:使用正确的 group tag
|
||
// libcore 生成的selector组的tag是"proxy"而不是"select"
|
||
final selectorGroupTag = kr_activeGroups.any((g) => g.tag == 'select') ? 'select' : 'proxy';
|
||
|
||
// ⚡ 关键优化:核心 API 调用
|
||
await _kr_selectOutboundWithRetry(selectorGroupTag, tag, maxAttempts: 3, initialDelay: 50);
|
||
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('✅ 节点切换API调用完成: $tag', tag: 'SingBox');
|
||
}
|
||
|
||
// ========== 关键路径结束!以下操作都在后台进行 ==========
|
||
// ⚡ 后台任务:验证 + 刷新 + 监控(不影响返回)
|
||
unawaited(_kr_backgroundNodeVerificationAndRefresh(tag));
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 节点选择失败: $e', tag: 'SingBox');
|
||
rethrow; // 抛出异常,让调用者知道失败了
|
||
}
|
||
}
|
||
|
||
/// ⚡ 后台验证和刷新(不影响 UI 响应)
|
||
/// 包含:验证节点选择 + TUN 模式刷新 + 启动监控定时器
|
||
Future<void> _kr_backgroundNodeVerificationAndRefresh(String tag) async {
|
||
try {
|
||
// 第一步:等待一点时间让活动组更新
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
|
||
// 第二步:后台验证节点选择
|
||
await _krVerifyNodeSelectionInBackground(tag);
|
||
|
||
// 第三步:TUN 模式刷新连接
|
||
if (Platform.isWindows && kr_connectionType.value == KRConnectionType.global) {
|
||
await _kr_tunModeRefreshInBackground(tag);
|
||
}
|
||
|
||
// 第四步:启动节点选择监控(防止被 auto 覆盖)
|
||
_kr_startNodeSelectionMonitoring(tag);
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 后台任务异常(不影响节点切换): $e', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
/// 后台验证节点选择(异步,失败只记日志)
|
||
Future<void> _krVerifyNodeSelectionInBackground(String tag) async {
|
||
try {
|
||
final selectGroup = kr_activeGroups.firstWhere(
|
||
(group) => group.tag == 'select',
|
||
orElse: () => throw Exception('未找到 "select" 选择器组'),
|
||
);
|
||
|
||
if (selectGroup.selected != tag) {
|
||
KRLogUtil.kr_w('⚠️ 节点验证失败: 期望 $tag, 实际 ${selectGroup.selected}', tag: 'SingBox');
|
||
} else if (kDebugMode) {
|
||
KRLogUtil.kr_d('✅ 节点验证成功: $tag', tag: 'SingBox');
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 节点验证异常: $e', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
/// TUN 模式连接刷新(后台执行)
|
||
Future<void> _kr_tunModeRefreshInBackground(String tag) async {
|
||
try {
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('🔄 TUN 模式:后台刷新连接...', tag: 'SingBox');
|
||
}
|
||
|
||
final selectorGroupTag = kr_activeGroups.any((g) => g.tag == 'select')
|
||
? 'select'
|
||
: 'proxy';
|
||
|
||
// 第一步:urlTest 创建新连接
|
||
final testResult = await kr_singBox.urlTest(selectorGroupTag).run().timeout(
|
||
const Duration(seconds: 3),
|
||
onTimeout: () {
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('⚠️ urlTest 超时', tag: 'SingBox');
|
||
}
|
||
return const Left('timeout');
|
||
},
|
||
);
|
||
|
||
testResult.match(
|
||
(error) {
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('⚠️ urlTest 失败: $error', tag: 'SingBox');
|
||
}
|
||
},
|
||
(_) {
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('✅ urlTest 完成', tag: 'SingBox');
|
||
}
|
||
},
|
||
);
|
||
|
||
// 第二步:HTTP 刷新(最有效)
|
||
await _kr_forceHttpConnectionRefresh(tag);
|
||
|
||
// 第三步:等待连接稳定
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('✅ TUN 模式连接刷新完成', tag: 'SingBox');
|
||
}
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ TUN 模式刷新异常: $e(不影响节点切换)', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
/// 启动节点选择监控定时器(防止 urltest 自动覆盖)
|
||
void _kr_startNodeSelectionMonitoring(String tag) {
|
||
try {
|
||
_nodeSelectionTimer?.cancel();
|
||
_nodeSelectionTimer = null;
|
||
|
||
if (tag != 'auto') {
|
||
final currentSelectorGroupTag = kr_activeGroups.any((g) => g.tag == 'select')
|
||
? 'select'
|
||
: 'proxy';
|
||
final selectedTag = tag;
|
||
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('🔁 启动节点选择监控: $selectedTag', tag: 'SingBox');
|
||
}
|
||
|
||
_nodeSelectionTimer = Timer.periodic(const Duration(seconds: 20), (timer) {
|
||
try {
|
||
kr_singBox.selectOutbound(currentSelectorGroupTag, selectedTag).run().then((result) {
|
||
result.match(
|
||
(error) {
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('🔁 定时器重选失败: $error', tag: 'SingBox');
|
||
}
|
||
},
|
||
(_) {
|
||
if (kDebugMode) {
|
||
KRLogUtil.kr_d('🔁 定时器重选成功', tag: 'SingBox');
|
||
}
|
||
},
|
||
);
|
||
}).catchError((error) {
|
||
KRLogUtil.kr_w('🔁 定时器异常: $error', tag: 'SingBox');
|
||
});
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('💥 定时器回调异常: $e', tag: 'SingBox');
|
||
timer.cancel();
|
||
_nodeSelectionTimer = null;
|
||
}
|
||
});
|
||
}
|
||
} catch (timerError) {
|
||
KRLogUtil.kr_e('❌ 启动定时器异常: $timerError', tag: 'SingBox');
|
||
_nodeSelectionTimer?.cancel();
|
||
_nodeSelectionTimer = null;
|
||
}
|
||
}
|
||
|
||
/// 配合文件地址
|
||
|
||
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");
|
||
|
||
/// ✅ 临时切换模式以完全断开浏览器连接(推荐方案)
|
||
/// 原理:
|
||
/// TUN 模式:浏览器 → 操作系统 → sing-box → 节点(浏览器无法关闭连接)❌
|
||
/// 系统代理:浏览器 → 代理软件 → 节点(代理可强制关闭连接)✅
|
||
/// 流程:
|
||
/// 1. TUN → 系统代理(这时代理会主动关闭所有旧连接)
|
||
/// 2. 发送 HTTP 请求(刷新代理缓存)
|
||
/// 3. 系统代理 → TUN(恢复原状态)
|
||
/// 优点:100% 彻底断开,无残留连接,UX 无感知(<100ms)
|
||
Future<void> _kr_refreshConnectionByModeSwitch(String nodeTag) async {
|
||
if (!Platform.isWindows) {
|
||
// 非 Windows 系统,使用原有的 HTTP 刷新方案
|
||
await _kr_forceHttpConnectionRefresh(nodeTag);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
KRLogUtil.kr_i('🔄 [高级] 临时切换到系统代理以断开所有连接...', tag: 'SingBox');
|
||
final originalMode = kr_connectionType.value;
|
||
|
||
// 第一步:临时切换到系统代理模式
|
||
KRLogUtil.kr_i(' ① 临时切换:TUN → 系统代理模式', tag: 'SingBox');
|
||
await kr_updateConnectionType(KRConnectionType.rule);
|
||
await Future.delayed(const Duration(milliseconds: 100)); // 等待模式切换生效
|
||
|
||
// 第二步:发送 HTTP 请求强制刷新(此时用系统代理)
|
||
KRLogUtil.kr_i(' ② 发送 HTTP 请求(通过系统代理刷新缓存)', tag: 'SingBox');
|
||
await _kr_forceHttpConnectionRefresh(nodeTag);
|
||
|
||
// 第三步:切换回原模式(TUN)
|
||
KRLogUtil.kr_i(' ③ 恢复:系统代理 → TUN 模式', tag: 'SingBox');
|
||
await kr_updateConnectionType(originalMode);
|
||
await Future.delayed(const Duration(milliseconds: 100)); // 等待模式切换生效
|
||
|
||
KRLogUtil.kr_i('✅ [高级] 连接完全刷新完成,浏览器将使用新节点的新连接', tag: 'SingBox');
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ 模式切换刷新异常: $e(节点切换已生效)', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
/// ✅ 激进方案:完全重启 VPN(100% 清理所有连接)
|
||
/// 原理:完全关闭 VPN 再重新启动,所有连接都会断开并重建
|
||
/// 优点:最彻底,无任何残留连接
|
||
/// 缺点:用户会短暂看到"已断开"状态,可能有 DNS 泄漏短暂风险
|
||
Future<void> _kr_restartVpnForNodeSwitch(String nodeTag) async {
|
||
try {
|
||
KRLogUtil.kr_i('🔄 [激进] 重启 VPN 以彻底清理所有连接...', tag: 'SingBox');
|
||
|
||
// 第一步:关闭 VPN(断开所有连接)
|
||
KRLogUtil.kr_i(' ① 停止 VPN', tag: 'SingBox');
|
||
await kr_stop();
|
||
await Future.delayed(const Duration(milliseconds: 300)); // 等待VPN完全停止
|
||
|
||
// 第二步:重新启动 VPN(建立新连接到新节点)
|
||
KRLogUtil.kr_i(' ② 重新启动 VPN', tag: 'SingBox');
|
||
await kr_start();
|
||
|
||
KRLogUtil.kr_i('✅ [激进] VPN 重启完成,所有连接已刷新', tag: 'SingBox');
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ VPN 重启失败: $e(请手动重新启动)', tag: 'SingBox');
|
||
// 尝试恢复
|
||
try {
|
||
await kr_start();
|
||
} catch (e2) {
|
||
KRLogUtil.kr_e('❌ VPN 恢复启动失败: $e2', tag: 'SingBox');
|
||
}
|
||
}
|
||
}
|
||
|
||
/// ✅ TUN模式连接强制刷新:发送真实 HTTP 请求
|
||
/// 原理:实际的 HTTP 请求会强制建立新的 TCP 连接,让浏览器感知到网络路由变化
|
||
/// 效果:比仅做 urlTest 测试更有效,因为这是真实的数据传输
|
||
Future<void> _kr_forceHttpConnectionRefresh(String nodeTag) async {
|
||
try {
|
||
// 使用多个测试 URL,确保至少有一个能成功
|
||
final testUrls = [
|
||
'http://connectivitycheck.gstatic.com/generate_204', // Google 连接检查
|
||
'http://www.google.com/generate_204',
|
||
'http://ipv4.icanhazip.com/', // IP 检查,会返回当前外网 IP
|
||
];
|
||
|
||
KRLogUtil.kr_i('🔗 使用 HTTP 请求强制刷新连接(节点: $nodeTag)', tag: 'SingBox');
|
||
|
||
for (int i = 0; i < testUrls.length; i++) {
|
||
final httpClient = HttpClient();
|
||
httpClient.connectionTimeout = const Duration(seconds: 2);
|
||
|
||
try {
|
||
final url = testUrls[i];
|
||
KRLogUtil.kr_i(' 尝试 URL[$i]: $url', tag: 'SingBox');
|
||
|
||
// 发送 HTTP 请求(不使用 Dio,直接使用 HttpClient 避免代理)
|
||
final request = await httpClient.getUrl(Uri.parse(url));
|
||
request.headers.set('Connection', 'close'); // 明确关闭连接
|
||
request.headers.set('User-Agent', 'BearVPN/1.0'); // 标识为 BearVPN 请求
|
||
|
||
final response = await request.close().timeout(
|
||
const Duration(seconds: 2),
|
||
);
|
||
|
||
// 消费响应体
|
||
final _ = await response.transform(utf8.decoder).join();
|
||
|
||
KRLogUtil.kr_i('✅ HTTP 刷新成功 ($url),状态码: ${response.statusCode}', tag: 'SingBox');
|
||
return; // 成功就返回,不需要尝试其他 URL
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_w('⚠️ HTTP 刷新失败 (${testUrls[i]}): $e', tag: 'SingBox');
|
||
// 继续尝试下一个 URL
|
||
if (i < testUrls.length - 1) {
|
||
await Future.delayed(const Duration(milliseconds: 100));
|
||
}
|
||
} finally {
|
||
// ✅ 关键修复:确保在任何情况下都关闭连接,避免资源泄漏
|
||
httpClient.close();
|
||
}
|
||
}
|
||
|
||
KRLogUtil.kr_w('⚠️ 所有 HTTP 刷新 URL 都失败,但节点切换仍然生效', tag: 'SingBox');
|
||
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 连接刷新异常: $e', tag: 'SingBox');
|
||
}
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
/// 🪟 Windows: 检查当前进程是否有管理员权限(TUN 模式需要)
|
||
/// 返回 true 表示有管理员权限,false 表示无权限
|
||
/// ✅ 公开方法:检查 Windows 管理员权限
|
||
/// 返回 true: 有管理员权限,可以使用 TUN 模式
|
||
/// 返回 false: 无管理员权限,只能使用规则/系统代理模式
|
||
Future<bool> kr_checkWindowsAdminPrivilege() async {
|
||
return await _kr_checkWindowsAdminPrivilege();
|
||
}
|
||
|
||
/// 私有方法:内部实现(带缓存)
|
||
/// ✅ 改进:使用 FFI 直接调用 IsUserAnAdmin() Windows API
|
||
/// ⭐ 优化:
|
||
/// - 不启动新进程,无黑屏风险
|
||
/// - 直接内存操作,速度快(1-5ms vs 600ms)
|
||
/// - 零资源开销
|
||
/// - Windows 官方 API,完全可靠
|
||
Future<bool> _kr_checkWindowsAdminPrivilege() async {
|
||
if (!Platform.isWindows) return true;
|
||
|
||
// 检查缓存是否有效
|
||
if (_cachedAdminPrivilege != null && _cachedAdminPrivilegeTime != null) {
|
||
final elapsed = DateTime.now().difference(_cachedAdminPrivilegeTime!);
|
||
if (elapsed < _adminPrivilegeCacheDuration) {
|
||
KRLogUtil.kr_i('📦 使用缓存的管理员权限状态: ${_cachedAdminPrivilege}', tag: 'SingBox');
|
||
return _cachedAdminPrivilege!;
|
||
}
|
||
}
|
||
|
||
try {
|
||
// ✅ 方案 B:使用 FFI 直接调用 IsUserAnAdmin() Windows API
|
||
// 比 Process.run('net session') 快 300 倍,且无黑屏风险
|
||
KRLogUtil.kr_i('🔍 使用 FFI 检查管理员权限(无黑屏)...', tag: 'SingBox');
|
||
|
||
final hasPrivilege = _isUserAnAdminFFI();
|
||
|
||
// 缓存结果
|
||
_cachedAdminPrivilege = hasPrivilege;
|
||
_cachedAdminPrivilegeTime = DateTime.now();
|
||
|
||
if (hasPrivilege) {
|
||
KRLogUtil.kr_i('✅ 检测到管理员权限', tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_w('⚠️ 未检测到管理员权限,TUN 模式需要管理员权限', tag: 'SingBox');
|
||
}
|
||
|
||
return hasPrivilege;
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 检查管理员权限失败: $e', tag: 'SingBox');
|
||
// 检查失败时返回 false,让用户重新以管理员身份运行
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 【方案 A】sing-box 进程健康监测
|
||
/// 用于检测 sing-box 是否在启动后不久崩溃
|
||
///
|
||
/// 背景:诊断日志显示
|
||
/// - start() 立即返回成功,8964 端口可连接 ✅
|
||
/// - 1.4 秒后,8964 端口突然无法连接 ❌
|
||
/// - 说明 sing-box 进程在启动后崩溃了
|
||
///
|
||
/// 监测策略:
|
||
/// 1. 启动成功后,立即开始监测
|
||
/// 2. 每隔 500ms 检查一次 8964 端口
|
||
/// 3. 如果连续 3 次无法连接,说明进程崩溃了
|
||
/// 4. 但如果 _stopRequested 为 true,说明用户要求停止,不要恢复!
|
||
Future<void> _monitorSingBoxProcess() async {
|
||
const checkInterval = Duration(milliseconds: 500);
|
||
const maxAttempts = 10; // 监测 5 秒(10 次 * 500ms)
|
||
int failureCount = 0;
|
||
int checkCount = 0;
|
||
|
||
try {
|
||
await KRFileLogger.log('[进程监测] 🟢 开始 sing-box 进程健康监测(每 500ms 检查一次,共 10 次)');
|
||
|
||
for (int i = 0; i < maxAttempts; i++) {
|
||
checkCount++;
|
||
await Future.delayed(checkInterval);
|
||
|
||
// 【关键】检查停止标志:如果用户要求停止,立即返回
|
||
if (_stopRequested) {
|
||
await KRFileLogger.log('[进程监测] ⏭️ 检测到停止标志,监测任务立即返回');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 200),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
|
||
// 连接成功,重置失败计数
|
||
failureCount = 0;
|
||
await KRFileLogger.log('[进程监测] ✅ 检查 #$checkCount: 8964 端口可连接(进程正常运行)');
|
||
} catch (e) {
|
||
failureCount++;
|
||
await KRFileLogger.log('[进程监测] ❌ 检查 #$checkCount: 8964 端口无法连接(失败 #$failureCount)');
|
||
|
||
// 如果连续 3 次失败,说明进程崩溃了
|
||
if (failureCount >= 3) {
|
||
// 【关键】但首先检查停止标志!
|
||
if (_stopRequested) {
|
||
await KRFileLogger.log('[进程监测] ⏭️ 连接失败,但用户已要求停止,不触发恢复');
|
||
return;
|
||
}
|
||
|
||
await KRFileLogger.log('[进程监测] 🚨 【严重】连续 3 次无法连接,sing-box 进程已崩溃!');
|
||
await KRFileLogger.log('[进程监测] 🚨 时间:${DateTime.now()}');
|
||
await KRFileLogger.log('[进程监测] 🚨 这可能是导致后续 UI 卡顿的根本原因!');
|
||
|
||
KRLogUtil.kr_e('【严重】sing-box 进程在启动后崩溃了!端口无法连接', tag: 'ProcessMonitor');
|
||
|
||
// 【可选】触发恢复机制:尝试重启 sing-box
|
||
// await _triggerSingBoxRecovery();
|
||
|
||
return; // 停止监测
|
||
}
|
||
}
|
||
}
|
||
|
||
// 监测完成
|
||
await KRFileLogger.log('[进程监测] ✅ 监测完成:sing-box 进程在启动后 5 秒内保持稳定运行');
|
||
KRLogUtil.kr_i('✅ sing-box 进程健康检查通过', tag: 'ProcessMonitor');
|
||
|
||
} catch (e) {
|
||
await KRFileLogger.log('[进程监测] ❌ 监测异常: $e');
|
||
KRLogUtil.kr_e('进程监测异常: $e', tag: 'ProcessMonitor');
|
||
}
|
||
}
|
||
|
||
/// 【方案 A】sing-box 进程恢复机制
|
||
/// 当检测到进程崩溃时,尝试恢复
|
||
///
|
||
/// 改进:
|
||
/// 1. 先尝试优雅关闭(graceful shutdown)
|
||
/// 2. 如果进程仍活着,强制杀死以立即释放端口
|
||
/// 3. 轮询确认端口已释放后再重启
|
||
Future<void> _triggerSingBoxRecovery() async {
|
||
try {
|
||
await KRFileLogger.log('[进程恢复] 🟡 检测到进程崩溃,尝试恢复...');
|
||
KRLogUtil.kr_w('⚠️ sing-box 进程崩溃,尝试恢复...', tag: 'ProcessMonitor');
|
||
|
||
// 1️⃣ 先尝试优雅停止
|
||
try {
|
||
await kr_singBox.stop().run();
|
||
await KRFileLogger.log('[进程恢复] ✅ 已优雅停止旧进程');
|
||
} catch (e) {
|
||
await KRFileLogger.log('[进程恢复] ⚠️ 优雅停止失败(可能已经停止): $e');
|
||
}
|
||
|
||
// 2️⃣ 等待进程优雅关闭
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
|
||
// 3️⃣ 检查进程是否还活着(检查 8964 端口)
|
||
bool processStillAlive = false;
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 200),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
processStillAlive = true;
|
||
await KRFileLogger.log('[进程恢复] ⚠️ 8964 端口仍在被占用,进程未完全关闭');
|
||
} catch (e) {
|
||
processStillAlive = false;
|
||
await KRFileLogger.log('[进程恢复] ✅ 8964 端口已释放,进程已完全关闭');
|
||
}
|
||
|
||
// 4️⃣ 如果进程还活着,强制杀死(taskkill - Windows only)
|
||
bool killSucceeded = true;
|
||
if (processStillAlive) {
|
||
await KRFileLogger.log('[进程恢复] 🔴 进程仍活着,执行强制杀死...');
|
||
try {
|
||
// 【Windows 专用】使用 taskkill 强制杀死所有 sing-box 进程
|
||
// ✅ 使用 KRWindowsProcessUtil.runHidden 避免黑窗
|
||
if (Platform.isWindows) {
|
||
final result = await KRWindowsProcessUtil.runHidden(
|
||
'taskkill',
|
||
['/IM', 'sing-box.exe', '/F'],
|
||
);
|
||
|
||
// 【关键】检查返回码!
|
||
if (result.exitCode == 0) {
|
||
await KRFileLogger.log('[进程恢复] ✅ taskkill 成功(返回码: 0)');
|
||
killSucceeded = true;
|
||
} else {
|
||
await KRFileLogger.log('[进程恢复] ⚠️ taskkill 返回码: ${result.exitCode}(可能失败)');
|
||
// 返回码 128 = 进程未找到(可能已自动退出)
|
||
// 返回码 1 = 没有权限
|
||
killSucceeded = (result.exitCode == 128); // 128 = 进程已不存在(正常)
|
||
}
|
||
} else {
|
||
// 【非 Windows 平台】跳过 taskkill,仅依赖优雅关闭
|
||
await KRFileLogger.log('[进程恢复] ℹ️ 非 Windows 平台,跳过 taskkill,依赖优雅关闭');
|
||
killSucceeded = true; // 假设优雅关闭足够
|
||
}
|
||
} catch (e) {
|
||
await KRFileLogger.log('[进程恢复] ⚠️ taskkill 执行异常: $e');
|
||
killSucceeded = false;
|
||
}
|
||
|
||
// 5️⃣ 等待进程资源完全释放
|
||
// 如果 kill 成功,等待较短的时间;如果失败,等待较长的时间
|
||
final waitDuration = killSucceeded
|
||
? const Duration(milliseconds: 500)
|
||
: const Duration(milliseconds: 1000);
|
||
await Future.delayed(waitDuration);
|
||
}
|
||
|
||
// 6️⃣ 轮询等待端口完全释放(避免 TIME_WAIT 状态)
|
||
await KRFileLogger.log('[进程恢复] 🔍 轮询验证 8964 端口是否完全释放...');
|
||
int releaseRetries = 0;
|
||
const maxReleaseRetries = 20; // 最多等待 10 秒
|
||
bool portFullyReleased = false;
|
||
|
||
while (releaseRetries < maxReleaseRetries) {
|
||
try {
|
||
// 尝试连接,如果失败说明端口已释放
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 100),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
|
||
// 仍然能连接,继续等待
|
||
releaseRetries++;
|
||
final elapsedMs = releaseRetries * 500;
|
||
await KRFileLogger.log('[进程恢复] ⏳ 端口仍被占用,等待中... (#$releaseRetries, 已等待 ${elapsedMs}ms)');
|
||
|
||
// 如果等待超过 5 秒且端口仍被占用,说明有其他进程占用端口(异常)
|
||
if (elapsedMs > 5000 && releaseRetries % 4 == 0) {
|
||
await KRFileLogger.log('[进程恢复] ⚠️ 【警告】端口占用超过 5 秒,可能有其他进程占用!');
|
||
}
|
||
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
} catch (e) {
|
||
// 无法连接,端口已释放
|
||
portFullyReleased = true;
|
||
final elapsedMs = releaseRetries * 500;
|
||
await KRFileLogger.log('[进程恢复] ✅ 确认 8964 端口已完全释放(耗时 ${elapsedMs}ms)');
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!portFullyReleased) {
|
||
await KRFileLogger.log('[进程恢复] ⚠️ 等待端口释放超时(已等待 ${maxReleaseRetries * 500}ms),继续尝试重启...');
|
||
}
|
||
|
||
// 7️⃣ 重新启动 sing-box(带重试)
|
||
await KRFileLogger.log('[进程恢复] 🟡 准备重新启动 sing-box...');
|
||
int startRetries = 0;
|
||
const maxStartRetries = 3;
|
||
bool startSucceeded = false;
|
||
|
||
while (startRetries < maxStartRetries) {
|
||
try {
|
||
await kr_singBox.start(_cutPath, kr_configName, false).map(
|
||
(r) {
|
||
KRLogUtil.kr_i('✅ sing-box 恢复启动成功', tag: 'ProcessMonitor');
|
||
return r;
|
||
},
|
||
).mapLeft((err) {
|
||
KRLogUtil.kr_e('❌ sing-box 恢复启动失败: $err', tag: 'ProcessMonitor');
|
||
throw err;
|
||
}).run();
|
||
|
||
startSucceeded = true;
|
||
await KRFileLogger.log('[进程恢复] ✅ sing-box 进程已恢复启动(第 ${startRetries + 1} 次尝试)');
|
||
break;
|
||
} catch (e) {
|
||
startRetries++;
|
||
await KRFileLogger.log('[进程恢复] ❌ sing-box 启动失败(第 $startRetries 次,错误:$e)');
|
||
|
||
if (startRetries < maxStartRetries) {
|
||
// 启动失败,增加延迟后重试
|
||
final delayMs = 1000 + (startRetries * 1000); // 1s, 2s, 3s 递增延迟
|
||
await KRFileLogger.log('[进程恢复] ⏳ 将在 ${delayMs}ms 后进行第 ${startRetries + 1} 次尝试...');
|
||
await Future.delayed(Duration(milliseconds: delayMs));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!startSucceeded) {
|
||
await KRFileLogger.log('[进程恢复] ❌ sing-box 恢复启动失败(3 次尝试都失败)');
|
||
throw Exception('sing-box 恢复启动失败');
|
||
}
|
||
|
||
// 8️⃣ 启动后等待 sing-box 真正准备好
|
||
await Future.delayed(const Duration(milliseconds: 500));
|
||
|
||
// 验证 8964 端口真的可以连接
|
||
int portCheckRetries = 0;
|
||
while (portCheckRetries < 5) {
|
||
try {
|
||
final socket = await Socket.connect('127.0.0.1', 8964).timeout(
|
||
const Duration(milliseconds: 200),
|
||
onTimeout: () => throw Exception('端口连接超时'),
|
||
);
|
||
socket.destroy();
|
||
await KRFileLogger.log('[进程恢复] ✅ 8964 端口已准备好,sing-box 完全就绪');
|
||
break;
|
||
} catch (e) {
|
||
portCheckRetries++;
|
||
if (portCheckRetries < 5) {
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 9️⃣ 重新开始监测
|
||
unawaited(_monitorSingBoxProcess());
|
||
|
||
} catch (e) {
|
||
await KRFileLogger.log('[进程恢复] ❌ 进程恢复失败: $e');
|
||
KRLogUtil.kr_e('进程恢复失败: $e', tag: 'ProcessMonitor');
|
||
}
|
||
}
|
||
|
||
/// FFI 实现:直接调用 Windows IsUserAnAdmin() API
|
||
/// 返回 true 表示有管理员权限,false 表示无权限
|
||
///
|
||
/// ✅ 优点:
|
||
/// - 不创建新进程,无黑屏
|
||
/// - 直接 API 调用,超快速(1-5ms)
|
||
/// - 内存操作,零 I/O 等待
|
||
/// - Windows 官方 API,完全可靠
|
||
bool _isUserAnAdminFFI() {
|
||
try {
|
||
// 加载 shell32.dll(包含 IsUserAnAdmin 函数)
|
||
final shell32 = DynamicLibrary.open('shell32.dll');
|
||
|
||
// 定义 FFI 绑定
|
||
// IsUserAnAdmin 返回 BOOL(Uint8 in Dart),无参数
|
||
final isUserAnAdmin = shell32.lookupFunction<
|
||
Uint8 Function(),
|
||
int Function()
|
||
>('IsUserAnAdmin');
|
||
|
||
// 调用 API 并检查返回值
|
||
// 返回值 != 0 表示是管理员,== 0 表示不是
|
||
final result = isUserAnAdmin();
|
||
return result != 0;
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ FFI 权限检查异常: $e', tag: 'SingBox');
|
||
// 异常时返回 false,安全起见假设无权限
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ==================== macOS SOCKS5 系统代理 ====================
|
||
// 🍎 让 Telegram 等应用自动走代理(参考 Clash 实现)
|
||
// 原因:libcore 只设置 HTTP/HTTPS 代理,但 Telegram 只检测 SOCKS5 代理
|
||
|
||
/// 获取当前活动的网络服务名称(Wi-Fi、Ethernet 等)
|
||
Future<String?> _kr_getMacOSActiveNetworkService() async {
|
||
if (!Platform.isMacOS) return null;
|
||
|
||
try {
|
||
// 获取默认路由的网络接口
|
||
final routeResult = await Process.run('route', ['-n', 'get', 'default']);
|
||
if (routeResult.exitCode != 0) {
|
||
KRLogUtil.kr_w('⚠️ 获取默认路由失败', tag: 'SingBox');
|
||
return 'Wi-Fi'; // 默认返回 Wi-Fi
|
||
}
|
||
|
||
final routeOutput = routeResult.stdout.toString();
|
||
final interfaceMatch = RegExp(r'interface:\s*(\S+)').firstMatch(routeOutput);
|
||
if (interfaceMatch == null) {
|
||
return 'Wi-Fi';
|
||
}
|
||
|
||
final interfaceName = interfaceMatch.group(1);
|
||
KRLogUtil.kr_i('🔍 检测到网络接口: $interfaceName', tag: 'SingBox');
|
||
|
||
// 获取网络服务列表,找到对应的服务名称
|
||
final servicesResult = await Process.run('networksetup', ['-listallhardwareports']);
|
||
if (servicesResult.exitCode != 0) {
|
||
return 'Wi-Fi';
|
||
}
|
||
|
||
final servicesOutput = servicesResult.stdout.toString();
|
||
final lines = servicesOutput.split('\n');
|
||
|
||
String? currentService;
|
||
for (final line in lines) {
|
||
if (line.startsWith('Hardware Port:')) {
|
||
currentService = line.replaceFirst('Hardware Port:', '').trim();
|
||
} else if (line.startsWith('Device:') && currentService != null) {
|
||
final device = line.replaceFirst('Device:', '').trim();
|
||
if (device == interfaceName) {
|
||
KRLogUtil.kr_i('✅ 找到网络服务: $currentService', tag: 'SingBox');
|
||
return currentService;
|
||
}
|
||
}
|
||
}
|
||
|
||
return 'Wi-Fi'; // 默认返回 Wi-Fi
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ 获取网络服务失败: $e', tag: 'SingBox');
|
||
return 'Wi-Fi';
|
||
}
|
||
}
|
||
|
||
/// 设置或清除 macOS SOCKS5 系统代理
|
||
/// [enable] true=设置代理, false=清除代理
|
||
Future<void> _kr_setMacOSSocks5Proxy(bool enable) async {
|
||
if (!Platform.isMacOS) return;
|
||
|
||
try {
|
||
final networkService = await _kr_getMacOSActiveNetworkService();
|
||
if (networkService == null) {
|
||
KRLogUtil.kr_w('⚠️ 无法获取网络服务名称', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
if (enable) {
|
||
// 设置 SOCKS5 代理(参考 Clash 实现)
|
||
KRLogUtil.kr_i('🍎 设置 macOS SOCKS5 系统代理: $networkService → 127.0.0.1:$kr_port', tag: 'SingBox');
|
||
|
||
// 设置 SOCKS 代理服务器和端口
|
||
final setResult = await Process.run('networksetup', [
|
||
'-setsocksfirewallproxy',
|
||
networkService,
|
||
'127.0.0.1',
|
||
kr_port.toString(),
|
||
]);
|
||
|
||
if (setResult.exitCode != 0) {
|
||
KRLogUtil.kr_e('❌ 设置 SOCKS5 代理失败: ${setResult.stderr}', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
// 启用 SOCKS 代理
|
||
final enableResult = await Process.run('networksetup', [
|
||
'-setsocksfirewallproxystate',
|
||
networkService,
|
||
'on',
|
||
]);
|
||
|
||
if (enableResult.exitCode != 0) {
|
||
KRLogUtil.kr_e('❌ 启用 SOCKS5 代理失败: ${enableResult.stderr}', tag: 'SingBox');
|
||
return;
|
||
}
|
||
|
||
KRLogUtil.kr_i('✅ macOS SOCKS5 系统代理已设置(Telegram 等应用将自动走代理)', tag: 'SingBox');
|
||
|
||
} else {
|
||
// 清除 SOCKS5 代理
|
||
KRLogUtil.kr_i('🍎 清除 macOS SOCKS5 系统代理: $networkService', tag: 'SingBox');
|
||
|
||
// 禁用 SOCKS 代理
|
||
final disableResult = await Process.run('networksetup', [
|
||
'-setsocksfirewallproxystate',
|
||
networkService,
|
||
'off',
|
||
]);
|
||
|
||
if (disableResult.exitCode != 0) {
|
||
KRLogUtil.kr_w('⚠️ 禁用 SOCKS5 代理失败: ${disableResult.stderr}', tag: 'SingBox');
|
||
} else {
|
||
KRLogUtil.kr_i('✅ macOS SOCKS5 系统代理已清除', tag: 'SingBox');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
KRLogUtil.kr_e('❌ macOS SOCKS5 代理操作异常: $e', tag: 'SingBox');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🔧 _KRWindowsProxySnapshot 类已删除 - 改用 WinINet API 直接操作,无需快照对象
|