修改flutter兼容版本,优化macos无法切换节点和协议不兼容等问题

(cherry picked from commit dcc07886f8ba73eb2630a14a81bda191468c7a1f)
This commit is contained in:
2025-10-30 02:50:00 -07:00
committed by speakeloudest
parent 8bba2441c2
commit 064a0a7402
8 changed files with 940 additions and 555 deletions
+3 -1
View File
@@ -58,7 +58,6 @@ app.*.map.json
/android/app/profile
/android/app/release
/data
/.gradle/
@@ -99,3 +98,6 @@ libcore/*.aar
# Android 编译产物
android/app/libs/*.aar
# FVM Version Cache
.fvm/
+31 -30
View File
@@ -138,7 +138,9 @@ class KROutboundItem {
"password": nodeListItem.uuid
};
break;
case "hysteria":
case "hysteria2":
// 后端的 "hysteria" 实际上是 Hysteria2 协议
final securityConfig =
json["security_config"] as Map<String, dynamic>? ?? {};
config = {
@@ -156,8 +158,7 @@ class KROutboundItem {
"tls": {
"enabled": true,
"server_name": securityConfig["sni"] ?? "",
"insecure": securityConfig["allow_insecure"] ?? true,
"alpn": ["h3"]
"insecure": securityConfig["allow_insecure"] ?? true
}
};
break;
@@ -260,6 +261,10 @@ class KROutboundItem {
print('📄 完整配置: $config');
break;
case "vless":
// 判断是否为域名(非IP地址)
final bool isDomain = !RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
.hasMatch(nodeListItem.serverAddr);
config = {
"type": "vless",
"tag": nodeListItem.name,
@@ -268,7 +273,7 @@ class KROutboundItem {
"uuid": nodeListItem.uuid,
"tls": {
"enabled": true,
"server_name": nodeListItem.serverAddr,
if (isDomain) "server_name": nodeListItem.serverAddr,
"insecure": true,
"utls": {
"enabled": true,
@@ -280,6 +285,10 @@ class KROutboundItem {
print('📄 完整配置: $config');
break;
case "vmess":
// 判断是否为域名(非IP地址)
final bool isDomain = !RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
.hasMatch(nodeListItem.serverAddr);
config = {
"type": "vmess",
"tag": nodeListItem.name,
@@ -290,7 +299,7 @@ class KROutboundItem {
"security": "auto",
"tls": {
"enabled": true,
"server_name": nodeListItem.serverAddr,
if (isDomain) "server_name": nodeListItem.serverAddr,
"insecure": true,
"utls": {"enabled": true, "fingerprint": "chrome"}
}
@@ -299,6 +308,10 @@ class KROutboundItem {
print('📄 完整配置: $config');
break;
case "trojan":
// 判断是否为域名(非IP地址)
final bool isDomain = !RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
.hasMatch(nodeListItem.serverAddr);
config = {
"type": "trojan",
"tag": nodeListItem.name,
@@ -307,7 +320,7 @@ class KROutboundItem {
"password": nodeListItem.uuid,
"tls": {
"enabled": true,
"server_name": nodeListItem.serverAddr,
if (isDomain) "server_name": nodeListItem.serverAddr,
"insecure": true,
"utls": {"enabled": true, "fingerprint": "chrome"}
}
@@ -316,42 +329,30 @@ class KROutboundItem {
print('📄 完整配置: $config');
break;
case "hysteria":
config = {
"type": "hysteria",
"tag": nodeListItem.name,
"server": nodeListItem.serverAddr,
"server_port": nodeListItem.port,
"up_mbps": 100,
"down_mbps": 100,
"auth_str": nodeListItem.uuid,
"tls": {
"enabled": true,
"server_name": nodeListItem.serverAddr,
"insecure": true,
"alpn": ["h3"]
}
};
print('✅ Hysteria 节点配置构建成功: ${nodeListItem.name}');
print('📄 完整配置: $config');
break;
case "hysteria2":
// 后端的 "hysteria" 实际上是 Hysteria2 协议
print('🔍 构建 Hysteria2 节点: ${nodeListItem.name}');
print(' - serverAddr: ${nodeListItem.serverAddr}');
print(' - port: ${nodeListItem.port}');
print(' - uuid: ${nodeListItem.uuid}');
//判断是否为域名
final bool isDomain = !RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
.hasMatch(nodeListItem.serverAddr);
config = {
"type": "hysteria2",
"tag": nodeListItem.name,
"server": nodeListItem.serverAddr,
"server_port": nodeListItem.port,
"password": nodeListItem.uuid,
"up_mbps": 100,
"down_mbps": 100,
"tls": {
"enabled": true,
"server_name": nodeListItem.serverAddr,
"insecure": true,
"alpn": ["h3"]
if (isDomain) "server_name": nodeListItem.serverAddr,
}
};
print('✅ Hysteria2 节点配置构建成功: ${nodeListItem.name}');
print('📄 完整配置: $config');
print('✅ Hysteria2 节点配置构建成功');
print('📄 完整配置: ${jsonEncode(config)}');
break;
default:
print('⚠️ 不支持的协议类型: ${nodeListItem.protocol}');
@@ -665,6 +665,8 @@ class KRHomeController extends GetxController with WidgetsBindingObserver {
try {
kr_isSwitching = true;
KRLogUtil.kr_i('🔄 开始切换连接状态: $value', tag: 'HomeController');
if (value) {
await KRSingBoxImp.instance.kr_start();
@@ -678,16 +680,28 @@ class KRHomeController extends GetxController with WidgetsBindingObserver {
kr_forceSyncConnectionStatus();
});
} else {
await KRSingBoxImp.instance.kr_stop();
KRLogUtil.kr_i('🛑 准备停止连接...', tag: 'HomeController');
// 添加超时保护
await KRSingBoxImp.instance.kr_stop().timeout(
const Duration(seconds: 10),
onTimeout: () {
KRLogUtil.kr_e('⚠️ 停止操作超时', tag: 'HomeController');
// 强制同步状态
kr_forceSyncConnectionStatus();
throw TimeoutException('Stop operation timeout');
},
);
KRLogUtil.kr_i('✅ 停止命令已发送', tag: 'HomeController');
}
} catch (e) {
KRLogUtil.kr_e('切换失败: $e', tag: 'HomeController');
// 当启动失败时(如VPN权限被拒绝),强制同步状态
// 当启动或停止失败时,强制同步状态
Future.delayed(const Duration(milliseconds: 100), () {
kr_forceSyncConnectionStatus();
});
} finally {
// 确保在任何情况下都会重置标志
KRLogUtil.kr_i('🔓 重置切换标志', tag: 'HomeController');
kr_isSwitching = false;
}
}
@@ -1088,10 +1102,11 @@ class KRHomeController extends GetxController with WidgetsBindingObserver {
// 更新连接信息
kr_updateConnectionInfo();
// 🔧 修复:只有在核心已启动时才选择节点,避免触发重启
if (KRSingBoxImp.instance.kr_status.value == SingboxStarted()) {
KRSingBoxImp.instance.kr_selectOutbound(tag);
// 🔧 修复选择节点后启动延迟值更新带超时保护
// 🔧 修复:选择节点后启动延迟值更新(带超时保护)
Future.delayed(const Duration(milliseconds: 500), () {
if (kr_currentNodeLatency.value == -1 && kr_isConnected.value) {
KRLogUtil.kr_w('⚠️ 选择节点后延迟值未更新,尝试手动更新', tag: 'HomeController');
@@ -1101,7 +1116,13 @@ class KRHomeController extends GetxController with WidgetsBindingObserver {
}
});
} else {
KRSingBoxImp().kr_start();
// 🔧 修复:核心未启动时,仍需保存用户选择,以便启动VPN时应用
KRLogUtil.kr_i('💾 核心未启动,保存节点选择以便稍后应用: $tag', tag: 'HomeController');
KRSecureStorage().kr_saveData(key: 'SELECTED_NODE_TAG', value: tag).then((_) {
KRLogUtil.kr_i('✅ 节点选择已保存: $tag', tag: 'HomeController');
}).catchError((e) {
KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'HomeController');
});
}
// 移动到选中的节点
+466 -55
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:fpdart/fpdart.dart';
import 'package:get/get.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -19,6 +20,7 @@ import '../../../singbox/model/singbox_stats.dart';
import '../../../singbox/model/singbox_status.dart';
import '../../utils/kr_country_util.dart';
import '../../utils/kr_log_util.dart';
import '../../utils/kr_secure_storage.dart';
enum KRConnectionType {
global,
@@ -45,6 +47,9 @@ class KRSingBoxImp {
/// 配置文件名称
String kr_configName = "hiFastVPN";
/// 存储键:用户选择的节点
static const String _keySelectedNode = 'SELECTED_NODE_TAG';
/// 通道方法
final _kr_methodChannel = const MethodChannel("com.hi.app/platform");
@@ -65,10 +70,10 @@ class KRSingBoxImp {
final kr_status = SingboxStatus.stopped().obs;
/// 拦截广告
final kr_blockAds = true.obs;
final kr_blockAds = false.obs;
/// 是否自动自动选择线路
final kr_isAutoOutbound = true.obs;
final kr_isAutoOutbound = false.obs;
bool _initialized = false;
@@ -396,8 +401,27 @@ class KRSingBoxImp {
}
}
// 🔑 关键步骤:调用 setup() 将 NativePort 传递给 libcore
// 这样 libcore 才能通过 GoDart_PostCObject() 向 Dart 发送消息
KRLogUtil.kr_i('📡 开始调用 setup() 注册 FFI 端口', tag: 'SingBox');
final setupResult = await kr_singBox.setup(kr_configDics, false).run();
setupResult.match(
(error) {
KRLogUtil.kr_e('❌ setup() 失败: $error', tag: 'SingBox');
throw Exception('FFI setup 失败: $error');
},
(_) {
KRLogUtil.kr_i('✅ setup() 成功,FFI 端口已注册', tag: 'SingBox');
},
);
KRLogUtil.kr_i('✅ SingBox 初始化完成');
_kr_isInitialized = true;
// 🔑 关键:在初始化完成后立即订阅状态变化流
// 这样可以确保 UI 始终与 libcore 的实际状态同步
_kr_subscribeToStatus();
KRLogUtil.kr_i('✅ 状态订阅已设置', tag: 'SingBox');
} catch (e, stackTrace) {
KRLogUtil.kr_e('❌ SingBox 初始化失败: $e');
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace');
@@ -438,20 +462,21 @@ class KRSingBoxImp {
}
Map<String, dynamic> _getConfigOption() {
if (kr_configOption.isNotEmpty) {
return kr_configOption;
}
// 不使用缓存,每次都重新生成配置
// if (kr_configOption.isNotEmpty) {
// return kr_configOption;
// }
final op = {
"region": KRCountryUtil.kr_getCurrentCountryCode(),
"block-ads": kr_blockAds.value,
"region": "other", // 参考 hiddify-app: 默认使用 "other" 跳过规则集下载
"block-ads": false, // 参考 hiddify-app: 默认关闭广告拦截
"use-xray-core-when-possible": false,
"execute-config-as-is": false,
"log-level": "warn",
"log-level": "info", // 调试阶段使用 info,生产环境改为 warn
"resolve-destination": false,
"ipv6-mode": "ipv4_only",
"remote-dns-address": "udp://8.8.8.8",
"ipv6-mode": "ipv4_only", // 参考 hiddify-app: 仅使用 IPv4 (有效值: ipv4_only, prefer_ipv4, prefer_ipv6, ipv6_only)
"remote-dns-address": "udp://1.1.1.1", // 参考 hiddify-app: 使用 Cloudflare DNS
"remote-dns-domain-strategy": "prefer_ipv4",
"direct-dns-address": "udp://1.1.1.1",
"direct-dns-address": "udp://1.1.1.1", // 参考 hiddify-app: 统一使用 1.1.1.1
"direct-dns-domain-strategy": "prefer_ipv4",
"mixed-port": kr_port,
"tproxy-port": kr_port,
@@ -459,8 +484,7 @@ class KRSingBoxImp {
"tun-implementation": "gvisor",
"mtu": 9000,
"strict-route": true,
// "connection-test-url": "http://www.cloudflare.com",
"connection-test-url": "http://www.gstatic.com/generate_204",
"connection-test-url": "http://cp.cloudflare.com", // 参考 hiddify-app: 使用 Cloudflare 测试端点
"url-test-interval": 30,
"enable-clash-api": true,
"clash-api-port": 36756,
@@ -521,6 +545,32 @@ class KRSingBoxImp {
return op;
}
/// 订阅状态变化流
/// 参考 hiddify-app: 监听 libcore 发送的状态事件来自动更新 UI
void _kr_subscribeToStatus() {
// 取消之前的状态订阅
for (var sub in _kr_subscriptions) {
if (sub.hashCode.toString().contains('Status')) {
sub.cancel();
}
}
_kr_subscriptions
.removeWhere((sub) => sub.hashCode.toString().contains('Status'));
_kr_subscriptions.add(
kr_singBox.watchStatus().listen(
(status) {
KRLogUtil.kr_i('📡 收到状态更新: $status', tag: 'SingBox');
kr_status.value = status;
},
onError: (error) {
KRLogUtil.kr_e('📡 状态流错误: $error', tag: 'SingBox');
},
cancelOnError: false,
),
);
}
/// 订阅统计数据流
void _kr_subscribeToStats() {
// 取消之前的统计订阅
@@ -532,17 +582,20 @@ class KRSingBoxImp {
_kr_subscriptions
.removeWhere((sub) => sub.hashCode.toString().contains('Stats'));
_kr_subscriptions.add(
kr_singBox.watchStats().listen(
(stats) {
kr_stats.value = stats;
},
onError: (error) {
KRLogUtil.kr_e('统计数据监听错误: $error');
},
cancelOnError: false,
),
// ⚠️ 关键:watchStats() 内部会调用 FFI startCommandClient
// 如果此时 command.sock 未就绪,会抛出异常
// 所以外层必须有 try-catch
final stream = kr_singBox.watchStats();
final subscription = stream.listen(
(stats) {
kr_stats.value = stats;
},
onError: (error) {
KRLogUtil.kr_e('统计数据监听错误: $error', tag: 'SingBox');
},
cancelOnError: false,
);
_kr_subscriptions.add(subscription);
}
/// 订阅分组数据流
@@ -594,6 +647,261 @@ class KRSingBoxImp {
);
}
/// 带重试机制的节点选择
///
/// 确保 command.sock 准备好后再执行节点选择
Future<void> _kr_selectOutboundWithRetry(
String groupTag,
String outboundTag, {
int maxAttempts = 3,
int initialDelay = 100,
}) async {
int attempt = 0;
int delay = initialDelay;
while (attempt < maxAttempts) {
attempt++;
KRLogUtil.kr_i(
'🔄 尝试选择节点 $outboundTag (第 $attempt/$maxAttempts 次)',
tag: 'SingBox',
);
// 只在失败后才延迟,首次尝试立即执行
if (attempt > 1) {
await Future.delayed(Duration(milliseconds: delay));
}
try {
await kr_singBox.selectOutbound(groupTag, outboundTag).run();
KRLogUtil.kr_i('✅ 节点选择成功: $outboundTag', tag: 'SingBox');
return;
} catch (e) {
KRLogUtil.kr_w(
'⚠️ 第 $attempt 次节点选择失败: $e',
tag: 'SingBox',
);
if (attempt >= maxAttempts) {
KRLogUtil.kr_e(
'❌ 节点选择失败,已达到最大重试次数',
tag: 'SingBox',
);
throw Exception('节点选择失败: $e');
}
// 指数退避
delay = delay * 2;
}
}
}
/// 带重试机制的命令客户端初始化
///
/// command.sock 需要时间创建,因此需要重试机制
/// maxAttempts: 最大重试次数(默认10次,针对macOS优化)
/// initialDelay: 初始延迟毫秒数(默认1000ms,针对macOS优化)
Future<void> _kr_initializeCommandClientsWithRetry({
int maxAttempts = 10,
int initialDelay = 1000,
}) async {
int attempt = 0;
int delay = initialDelay;
while (attempt < maxAttempts) {
attempt++;
KRLogUtil.kr_i(
'🔄 尝试初始化命令客户端 (第 $attempt/$maxAttempts 次,延迟 ${delay}ms)',
tag: 'SingBox',
);
await Future.delayed(Duration(milliseconds: delay));
try {
// 先验证 command.sock 是否可访问
final socketFile = File(p.join(kr_configDics.baseDir.path, 'command.sock'));
KRLogUtil.kr_i('🔍 检查 socket 文件: ${socketFile.path}', tag: 'SingBox');
if (!socketFile.existsSync()) {
throw Exception('command.sock 文件不存在: ${socketFile.path}');
}
KRLogUtil.kr_i('✅ socket 文件存在,开始订阅...', tag: 'SingBox');
// 尝试订阅统计和分组数据
// ⚠️ 关键:分别 try-catch,避免一个失败影响另一个
bool statsSubscribed = false;
bool groupsSubscribed = false;
// ⚠️ 关键修复:使用 Future.delayed(Duration.zero) 将订阅推到下一个事件循环
// 这样可以避免阻塞当前的异步执行
try {
KRLogUtil.kr_i('📊 订阅统计数据流...', tag: 'SingBox');
await Future.delayed(Duration.zero); // 让出 UI 线程
_kr_subscribeToStats();
statsSubscribed = true;
KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox');
} catch (e) {
KRLogUtil.kr_w('⚠️ 统计数据流订阅失败: $e', tag: 'SingBox');
}
try {
KRLogUtil.kr_i('📋 订阅分组数据流...', tag: 'SingBox');
await Future.delayed(Duration.zero); // 让出 UI 线程
_kr_subscribeToGroups();
groupsSubscribed = true;
KRLogUtil.kr_i('✅ 分组数据流订阅成功', tag: 'SingBox');
} catch (e) {
KRLogUtil.kr_w('⚠️ 分组数据流订阅失败: $e', tag: 'SingBox');
}
// 至少有一个订阅成功才算初始化成功
if (!statsSubscribed && !groupsSubscribed) {
throw Exception('所有订阅都失败了');
}
// 等待更长时间验证订阅是否成功,并实际接收到数据
KRLogUtil.kr_i('⏳ 等待800ms验证订阅状态...', tag: 'SingBox');
await Future.delayed(const Duration(milliseconds: 800));
// 验证订阅是否真正工作(检查是否有数据流)
if (_kr_subscriptions.isEmpty) {
throw Exception('订阅列表为空,command client 未成功连接');
}
KRLogUtil.kr_i('✅ 命令客户端初始化成功,活跃订阅数: ${_kr_subscriptions.length}', tag: 'SingBox');
return;
} catch (e, stackTrace) {
// 详细记录失败原因和堆栈信息
KRLogUtil.kr_w(
'⚠️ 第 $attempt 次尝试失败',
tag: 'SingBox',
);
KRLogUtil.kr_w('❌ 错误类型: ${e.runtimeType}', tag: 'SingBox');
KRLogUtil.kr_w('❌ 错误信息: $e', tag: 'SingBox');
KRLogUtil.kr_w('📚 错误堆栈: $stackTrace', tag: 'SingBox');
if (attempt >= maxAttempts) {
KRLogUtil.kr_e(
'❌ 命令客户端初始化失败,已达到最大重试次数 ($maxAttempts)',
tag: 'SingBox',
);
KRLogUtil.kr_e('💡 提示: command.sock 可能未准备好或权限不足', tag: 'SingBox');
throw Exception('命令客户端初始化失败: $e');
}
// 指数退避:每次失败后延迟翻倍(1000ms -> 2000ms -> 4000ms -> 8000ms
delay = delay * 2;
}
}
}
/// 在后台尝试预连接 command client(不阻塞启动流程)
///
/// 借鉴 Hiddify 的设计思想:
/// - watchStats/watchGroups 是懒加载的,首次调用时才会 startCommandClient()
/// - 但我们可以在后台提前尝试连接,提高成功率
/// - 即使失败也不影响主流程,真正的订阅会在 UI 调用时发生
void _kr_tryPreconnectCommandClientsInBackground() {
KRLogUtil.kr_i('🔄 后台启动 command client 预连接任务...', tag: 'SingBox');
// 使用 Future.microtask 确保不阻塞当前执行
Future.microtask(() async {
try {
// 等待 command.sock 就绪(macOS 需要更长时间)
await Future.delayed(const Duration(milliseconds: 1500));
// 检查 socket 文件
final socketFile = File(p.join(kr_configDics.baseDir.path, 'command.sock'));
if (!socketFile.existsSync()) {
KRLogUtil.kr_w('⚠️ command.sock 尚未创建,预连接取消', tag: 'SingBox');
return;
}
KRLogUtil.kr_i('✅ command.sock 已就绪,尝试预订阅...', tag: 'SingBox');
// ⚠️ 注意:这里只是"触发"订阅,不等待结果
// 如果失败,UI 调用时会重新尝试
try {
_kr_subscribeToStats();
KRLogUtil.kr_i('✅ 统计流预订阅成功', tag: 'SingBox');
} catch (e) {
KRLogUtil.kr_w('⚠️ 统计流预订阅失败(正常,UI 调用时会重试): $e', tag: 'SingBox');
}
try {
_kr_subscribeToGroups();
KRLogUtil.kr_i('✅ 分组流预订阅成功', tag: 'SingBox');
} catch (e) {
KRLogUtil.kr_w('⚠️ 分组流预订阅失败(正常,UI 调用时会重试): $e', tag: 'SingBox');
}
} catch (e) {
// 静默失败,不影响主流程
KRLogUtil.kr_w('⚠️ 后台预连接任务失败(不影响正常使用): $e', tag: 'SingBox');
}
});
}
/// 确保 command client 已初始化(通过触发订阅来初始化)
///
/// 借鉴 Hiddify:watchGroups() 会在首次调用时自动 startCommandClient()
Future<void> _kr_ensureCommandClientInitialized() async {
// 如果已经有订阅,说明 command client 已初始化
if (_kr_subscriptions.isNotEmpty) {
KRLogUtil.kr_i('✅ Command client 已初始化(订阅数: ${_kr_subscriptions.length}', tag: 'SingBox');
return;
}
KRLogUtil.kr_i('⚠️ Command client 未初始化,触发订阅...', tag: 'SingBox');
try {
// 触发 watchGroups(),这会自动调用 startCommandClient()
_kr_subscribeToGroups();
// 等待一小段时间让订阅建立
await Future.delayed(const Duration(milliseconds: 300));
if (_kr_subscriptions.isEmpty) {
throw Exception('订阅失败,command client 未初始化');
}
KRLogUtil.kr_i('✅ Command client 初始化成功', tag: 'SingBox');
} catch (e) {
KRLogUtil.kr_e('❌ Command client 初始化失败: $e', tag: 'SingBox');
rethrow;
}
}
/// 在后台恢复用户保存的节点选择(不阻塞启动流程)
///
/// selectOutbound() 依赖 command client,必须延迟执行
void _kr_restoreSavedNodeInBackground() {
KRLogUtil.kr_i('🔄 启动节点恢复后台任务...', tag: 'SingBox');
Future.microtask(() async {
try {
// 等待 command client 初始化
await Future.delayed(const Duration(milliseconds: 2000));
final savedNode = await KRSecureStorage().kr_readData(key: _keySelectedNode);
if (savedNode != null && savedNode.isNotEmpty && savedNode != 'auto') {
KRLogUtil.kr_i('🔄 恢复用户选择的节点: $savedNode', tag: 'SingBox');
try {
await _kr_selectOutboundWithRetry("select", savedNode);
KRLogUtil.kr_i('✅ 节点恢复完成', tag: 'SingBox');
} catch (e) {
KRLogUtil.kr_w('⚠️ 节点恢复失败(可能 command client 未就绪): $e', tag: 'SingBox');
}
} else {
KRLogUtil.kr_i('️ 使用默认节点选择 (auto)', tag: 'SingBox');
}
} catch (e) {
KRLogUtil.kr_w('⚠️ 节点恢复后台任务失败: $e', tag: 'SingBox');
}
});
}
/// 监听活动组的详细实现
// Future<void> watchActiveGroups() async {
// try {
@@ -643,7 +951,17 @@ class KRSingBoxImp {
KRLogUtil.kr_i(' - 完整配置: ${jsonEncode(outbound)}', tag: 'SingBox');
}
kr_outbounds = outbounds;
// ⚠️ 临时过滤 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');
// 只保存 outboundsMobile.buildConfig() 会添加其他配置
final map = {
@@ -673,12 +991,39 @@ class KRSingBoxImp {
}
Future<void> kr_start() async {
kr_status.value = SingboxStarting();
// 不再手动设置状态,libcore 会通过 status stream 自动发送状态更新
try {
KRLogUtil.kr_i('🚀 开始启动 SingBox...', tag: 'SingBox');
// ⚠️ 强制编译标记 - v2.0-lazy-load
KRLogUtil.kr_i('🚀🚀🚀 [v2.0-lazy-load] 开始启动 SingBox...', tag: 'SingBox');
KRLogUtil.kr_i('📁 配置文件路径: $_cutPath', tag: 'SingBox');
KRLogUtil.kr_i('📝 配置名称: $kr_configName', tag: 'SingBox');
// 🔑 先尝试停止旧实例,避免 command.sock 冲突
// 注意:如果没有旧实例,libcore 会报错 "command.sock: no such file",这是正常的
try {
await kr_singBox.stop().run();
await Future.delayed(const Duration(milliseconds: 500));
KRLogUtil.kr_i('✅ 已清理旧实例', tag: 'SingBox');
} catch (e) {
// 预期行为:没有旧实例时会报错,可以忽略
KRLogUtil.kr_i('️ 没有运行中的旧实例(正常)', tag: 'SingBox');
}
// 🔑 关键步骤:在 start 之前必须调用 changeOptions 初始化 HiddifyOptions
// 否则 libcore 的 StartService 会因为 HiddifyOptions == nil 而 panic
KRLogUtil.kr_i('📡 初始化 HiddifyOptions...', tag: 'SingBox');
final oOption = SingboxConfigOption.fromJson(_getConfigOption());
final changeResult = await kr_singBox.changeOptions(oOption).run();
changeResult.match(
(error) {
KRLogUtil.kr_e('❌ changeOptions() 失败: $error', tag: 'SingBox');
throw Exception('初始化 HiddifyOptions 失败: $error');
},
(_) {
KRLogUtil.kr_i('✅ HiddifyOptions 初始化成功', tag: 'SingBox');
},
);
// 检查配置文件是否存在
final configFile = File(_cutPath);
if (await configFile.exists()) {
@@ -695,19 +1040,26 @@ class KRSingBoxImp {
},
).mapLeft((err) {
KRLogUtil.kr_e('❌ SingBox 启动失败: $err', tag: 'SingBox');
// 确保状态重置为Stopped,触发UI更新
kr_status.value = SingboxStopped();
// 强制刷新状态以触发观察者
kr_status.refresh();
// 不需要手动设置状态,libcore 会通过 status stream 自动发送 Stopped 事件
throw err;
}).run();
// ⚠️ 借鉴 Hiddify 架构:不在 start() 时立即订阅 command client
// 原因:
// 1. 避免阻塞 UI 线程 - startCommandClient() 是同步 FFI 调用
// 2. watchStats/watchGroups 是懒加载的,首次调用时自动初始化
// 3. 如果 command.sock 未就绪,会在后台自动抛出异常,不影响启动流程
KRLogUtil.kr_i('✅ SingBox 核心已启动,command client 将延迟初始化', tag: 'SingBox');
KRLogUtil.kr_i('💡 watchStats/watchGroups 会在首次调用时自动连接', tag: 'SingBox');
// ⚠️ 完全不主动订阅,避免阻塞 UI 线程
// 借鉴 Hiddify:所有订阅由 UI 被动触发(懒加载)
KRLogUtil.kr_i('⚠️ 不再主动订阅 command client,完全由 UI 触发', tag: 'SingBox');
} catch (e, stackTrace) {
KRLogUtil.kr_e('💥 SingBox 启动异常: $e', tag: 'SingBox');
KRLogUtil.kr_e('📚 错误堆栈: $stackTrace', tag: 'SingBox');
// 确保状态重置为Stopped,触发UI更新
kr_status.value = SingboxStopped();
// 强制刷新状态以触发观察者
kr_status.refresh();
// 不需要手动设置状态,libcore 会自动处理
rethrow;
}
}
@@ -715,26 +1067,53 @@ class KRSingBoxImp {
/// 停止服务
Future<void> kr_stop() async {
try {
// 不主动赋值 kr_status
KRLogUtil.kr_i('🛑 停止 SingBox 服务...', tag: 'SingBox');
// 取消节点选择监控定时器
_nodeSelectionTimer?.cancel();
_nodeSelectionTimer = null;
KRLogUtil.kr_i('✅ 节点选择监控已停止', tag: 'SingBox');
await Future.delayed(const Duration(milliseconds: 100));
await kr_singBox.stop().run();
await Future.delayed(const Duration(milliseconds: 1000));
// 取消订阅
final subscriptions = List<StreamSubscription<dynamic>>.from(_kr_subscriptions);
_kr_subscriptions.clear();
for (var subscription in subscriptions) {
// 添加超时保护,防止 stop() 调用阻塞
// 缩短超时时间,避免用户等待过久
try {
await kr_singBox.stop().run().timeout(
const Duration(seconds: 3),
onTimeout: () {
KRLogUtil.kr_w('⚠️ 停止操作超时(3秒),强制继续', tag: 'SingBox');
return const Left('timeout');
},
);
} catch (e) {
KRLogUtil.kr_w('⚠️ 停止操作失败(可能已经停止): $e', tag: 'SingBox');
// 继续执行清理操作
}
await Future.delayed(const Duration(milliseconds: 500));
// 取消统计和分组订阅,但保留状态订阅以便继续接收状态更新
final subscriptionsToCancel = _kr_subscriptions.where((sub) {
final hashStr = sub.hashCode.toString();
return !hashStr.contains('Status'); // 不取消状态订阅
}).toList();
for (var subscription in subscriptionsToCancel) {
try {
await subscription.cancel();
_kr_subscriptions.remove(subscription);
} catch (e) {
KRLogUtil.kr_e('取消订阅时出错: $e');
}
}
// 不主动赋值 kr_status
// 不手动设置状态,由 libcore 通过 status stream 自动发送 Stopped 事件
KRLogUtil.kr_i('✅ SingBox 停止请求已发送', tag: 'SingBox');
} catch (e, stackTrace) {
KRLogUtil.kr_e('停止服务时出错: $e');
KRLogUtil.kr_e('错误堆栈: $stackTrace');
// 兜底,防止状态卡死
kr_status.value = SingboxStopped();
// 不手动设置状态,信任 libcore 的状态管理
rethrow;
}
}
@@ -859,20 +1238,52 @@ class KRSingBoxImp {
return kr_singBox.watchGroups();
}
void kr_selectOutbound(String tag) {
KRLogUtil.kr_i('🎯 开始选择出站节点: $tag', tag: 'SingBox');
// 节点选择监控定时器
Timer? _nodeSelectionTimer;
void kr_selectOutbound(String tag) async {
KRLogUtil.kr_i('🎯 [v2.1] 开始选择出站节点: $tag', 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');
}
// 持久化用户选择的节点(异步执行,不阻塞UI)
KRSecureStorage().kr_saveData(key: _keySelectedNode, value: tag).then((_) {
KRLogUtil.kr_i('✅ 节点选择已保存: $tag', tag: 'SingBox');
}).catchError((e) {
KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'SingBox');
});
// ⚠️ 关键修复:确保 command client 已初始化
// 借鉴 Hiddify:通过触发 watchGroups() 来初始化 command client
_kr_ensureCommandClientInitialized().then((_) {
KRLogUtil.kr_i('✅ Command client 已就绪,执行节点切换', tag: 'SingBox');
// 🔄 使用重试机制执行节点选择(异步执行,不阻塞UI)
_kr_selectOutboundWithRetry("select", tag, maxAttempts: 3, initialDelay: 50).catchError((e) {
KRLogUtil.kr_e('❌ 节点选择失败: $e', tag: 'SingBox');
});
}).catchError((e) {
KRLogUtil.kr_e('❌ Command client 初始化失败,节点切换取消: $e', tag: 'SingBox');
});
// 🔄 如果用户选择了具体节点(不是 auto),启动定期检查和重新选择
// 这是为了防止 urltest 自动覆盖用户的手动选择
_nodeSelectionTimer?.cancel();
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');
});
}
kr_singBox.selectOutbound("select", tag).run();
}
/// 配合文件地址
+20 -55
View File
@@ -3,13 +3,11 @@ import 'dart:convert';
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
// import 'package:combine/combine.dart'; // 暂时注释掉,使用 Isolate.run 替代
import 'package:combine/combine.dart';
import 'package:ffi/ffi.dart';
import 'package:fpdart/fpdart.dart';
import 'package:kaer_with_panels/core/model/directories.dart';
import 'package:kaer_with_panels/gen/singbox_generated_bindings.dart';
import 'package:kaer_with_panels/singbox/model/singbox_config_option.dart';
import 'package:kaer_with_panels/singbox/model/singbox_outbound.dart';
import 'package:kaer_with_panels/singbox/model/singbox_stats.dart';
@@ -32,9 +30,6 @@ class FFISingboxService with InfraLogger implements SingboxService {
Stream<SingboxStats>? _serviceStatsStream;
Stream<List<SingboxOutboundGroup>>? _outboundsStream;
/// 初始化标志,防止重复初始化
bool _isInitialized = false;
static SingboxNativeLibrary _gen() {
String fullPath = "";
if (Platform.environment.containsKey('FLUTTER_TEST')) {
@@ -53,26 +48,14 @@ class FFISingboxService with InfraLogger implements SingboxService {
}
@override
Future<void> init() async {
// 防止重复初始化
if (_isInitialized) {
loggy.debug("already initialized, skipping");
return;
}
loggy.debug("initializing");
_statusReceiver = ReceivePort('service status receiver');
final source = _statusReceiver
.asBroadcastStream()
.map((event) => jsonDecode(event as String))
.map(SingboxStatus.fromEvent);
final source = _statusReceiver.asBroadcastStream().map((event) => jsonDecode(event as String)).map(SingboxStatus.fromEvent);
_status = ValueConnectableStream.seeded(
source,
const SingboxStopped(),
).autoConnect();
_isInitialized = true;
}
@override
@@ -82,7 +65,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
) {
final port = _statusReceiver.sendPort.nativePort;
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
_box.setupOnce(NativeApi.initializeApiDLData);
final err = _box
@@ -111,7 +94,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
bool debug,
) {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final err = _box
.parse(
@@ -133,13 +116,10 @@ class FFISingboxService with InfraLogger implements SingboxService {
@override
TaskEither<String, Unit> changeOptions(SingboxConfigOption options) {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final json = jsonEncode(options.toJson());
final err = _box
.changeHiddifyOptions(json.toNativeUtf8().cast())
.cast<Utf8>()
.toDartString();
final err = _box.changeHiddifyOptions(json.toNativeUtf8().cast()).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
return left(err);
}
@@ -154,7 +134,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
String path,
) {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final response = _box
.generateConfig(
@@ -179,7 +159,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
) {
loggy.debug("starting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final err = _box
.start(
@@ -200,7 +180,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
@override
TaskEither<String, Unit> stop() {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final err = _box.stop().cast<Utf8>().toDartString();
if (err.isNotEmpty) {
@@ -220,7 +200,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
) {
loggy.debug("restarting, memory limit: [${!disableMemoryLimit}]");
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final err = _box
.restart(
@@ -278,10 +258,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
},
);
final err = _box
.startCommandClient(1, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
final err = _box.startCommandClient(1, receiver.sendPort.nativePort).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
loggy.error("error starting status command: $err");
throw err;
@@ -323,10 +300,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
);
try {
final err = _box
.startCommandClient(5, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
final err = _box.startCommandClient(5, receiver.sendPort.nativePort).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
logger.error("error starting group command: $err");
throw err;
@@ -370,10 +344,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
);
try {
final err = _box
.startCommandClient(13, receiver.sendPort.nativePort)
.cast<Utf8>()
.toDartString();
final err = _box.startCommandClient(13, receiver.sendPort.nativePort).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
logger.error("error starting: $err");
throw err;
@@ -389,7 +360,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
@override
TaskEither<String, Unit> selectOutbound(String groupTag, String outboundTag) {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final err = _box
.selectOutbound(
@@ -410,12 +381,9 @@ class FFISingboxService with InfraLogger implements SingboxService {
@override
TaskEither<String, Unit> urlTest(String groupTag) {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final err = _box
.urlTest(groupTag.toNativeUtf8().cast())
.cast<Utf8>()
.toDartString();
final err = _box.urlTest(groupTag.toNativeUtf8().cast()).cast<Utf8>().toDartString();
if (err.isNotEmpty) {
return left(err);
}
@@ -431,9 +399,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
@override
Stream<List<String>> watchLogs(String path) async* {
yield await _readLogFile(File(path));
yield* Watcher(path, pollingDelay: const Duration(seconds: 1))
.events
.asyncMap((event) async {
yield* Watcher(path, pollingDelay: const Duration(seconds: 1)).events.asyncMap((event) async {
if (event.type == ChangeType.MODIFY) {
await _readLogFile(File(path));
}
@@ -444,7 +410,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
@override
TaskEither<String, Unit> clearLogs() {
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
_logBuffer.clear();
return right(unit);
@@ -455,8 +421,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
Future<List<String>> _readLogFile(File file) async {
if (_logFilePosition == 0 && file.lengthSync() == 0) return [];
final content =
await file.openRead(_logFilePosition).transform(utf8.decoder).join();
final content = await file.openRead(_logFilePosition).transform(utf8.decoder).join();
_logFilePosition = file.lengthSync();
final lines = const LineSplitter().convert(content);
if (lines.length > 300) {
@@ -479,7 +444,7 @@ class FFISingboxService with InfraLogger implements SingboxService {
}) {
loggy.debug("generating warp config");
return TaskEither(
() => Isolate.run(
() => CombineWorker().execute(
() {
final response = _box
.generateWarpConfig(
+1 -1
View File
@@ -82,7 +82,7 @@ SPEC CHECKSUMS:
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
Executable → Regular
+392 -408
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -61,7 +61,7 @@ dependencies:
fpdart: ^1.1.0
dartx: ^1.2.0
rxdart: ^0.27.7
# combine: ^0.5.8 # 暂时移除,使用 Isolate.run 替代
combine: 0.5.7 # 精确版本,兼容 Flutter 3.24.3(与 hiddify-app 相同)
encrypt: ^5.0.0
path: ^1.8.3
path_provider: ^2.1.1
@@ -140,6 +140,7 @@ dev_dependencies:
flutter:
assets:
- assets/images/
- assets/geosite/
- assets/translations/strings_en.i18n.json
- assets/translations/strings_zh.i18n.json
- assets/translations/strings_es.i18n.json