diff --git a/lib/app/common/app_config.dart b/lib/app/common/app_config.dart index 385cab1..9bbbab6 100755 --- a/lib/app/common/app_config.dart +++ b/lib/app/common/app_config.dart @@ -31,7 +31,7 @@ class KRDomain { // static String kr_currentDomain = "apicn.bearvpn.top"; static List kr_baseDomains = ["api.hifast.biz", "api.airovpn.tel",]; - static String kr_currentDomain = "api.hifast.biz1"; + static String kr_currentDomain = "api.hifast.biz"; // 备用域名获取地址列表 static List kr_backupDomainUrls = [ @@ -427,12 +427,6 @@ class KRDomain { /// 预检测域名可用性(在应用启动时调用) static Future kr_preCheckDomains() async { - // Debug 模式下跳过域名预检测 - // if (kDebugMode) { - // KRLogUtil.kr_i('🐛 Debug 模式,跳过域名预检测', tag: 'KRDomain'); - // return; - // } - KRLogUtil.kr_i('🚀 开始预检测域名可用性', tag: 'KRDomain'); // 异步预检测,不阻塞应用启动 diff --git a/lib/app/common/app_run_data.dart b/lib/app/common/app_run_data.dart index ebc7297..44368e2 100755 --- a/lib/app/common/app_run_data.dart +++ b/lib/app/common/app_run_data.dart @@ -77,6 +77,24 @@ class KRAppRunData { return kr_account.value != null && kr_account.value!.startsWith('9000'); } + /// 🔧 P1修复: 重置所有运行时状态(用于应用恢复/热重载) + /// 注意: 不会清除持久化存储的数据,只重置内存状态 + Future kr_resetRuntimeState() async { + try { + print('🔄 开始重置 KRAppRunData 运行时状态...'); + + // 重新从存储加载用户信息,确保状态同步 + await kr_initializeUserInfo(); + + print('✅ KRAppRunData 状态已重置'); + print(' - 登录状态: ${kr_isLogin.value}'); + print(' - 账号: ${kr_account.value}'); + print(' - Token存在: ${kr_token != null && kr_token!.isNotEmpty}'); + } catch (e) { + print('⚠️ KRAppRunData 状态重置失败: $e'); + } + } + /// 🔧 修复2.1:验证Token格式是否有效 /// 检查Token是否符合JWT格式(header.payload.signature) /// 这能有效防止被污染的或过期的Token数据 diff --git a/lib/app/modules/kr_home/controllers/kr_home_controller.dart b/lib/app/modules/kr_home/controllers/kr_home_controller.dart index 190f764..7518d1a 100755 --- a/lib/app/modules/kr_home/controllers/kr_home_controller.dart +++ b/lib/app/modules/kr_home/controllers/kr_home_controller.dart @@ -16,7 +16,6 @@ import '../../../localization/app_translations.dart'; import '../../../localization/kr_language_utils.dart'; import '../../../model/business/kr_group_outbound_list.dart'; import '../../../model/business/kr_outbound_item.dart'; -import '../../../services/kr_announcement_service.dart'; import '../../../utils/kr_event_bus.dart'; import '../../../utils/kr_update_util.dart'; import '../../../widgets/dialogs/kr_dialog.dart'; @@ -87,6 +86,15 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { // 是否已连接 final kr_isConnected = false.obs; + // Prevent rapid toggles from racing start/stop. + final kr_isToggleBusy = false.obs; + DateTime? _lastToggleRequestTime; + // 🔧 关键修复:缩短防抖窗口从 1500ms → 300ms + // 原因:防抖的真实目的是防止连击,不是防止反向操作 + // kr_isToggleBusy 已经防止了并发执行,防抖只需要防止单次按钮连击即可 + // Windows 上 DNS/proxy 恢复需要 2-4 秒,不能用防抖窗口来限制用户的反向操作 + static const Duration _toggleDebounceWindow = Duration(milliseconds: 300); + // 是否显示延迟 final kr_isLatency = false.obs; @@ -116,6 +124,7 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { // 🔧 保守修复:添加状态变化时间追踪,用于检测状态卡住 DateTime? _lastStatusChangeTime; + String? _lastStoppedErrorSignature; Timer? _statusWatchdogTimer; // 当前选中的组 @@ -311,12 +320,19 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { // 绑定连接状态 _bindConnectionStatus(); + // ✅ 参考 Hiddify:监听模式切换并自动重连 + // 当用户改变连接模式(全局/规则)时,自动重新连接以应用新配置 + _setupConnectionModeListener(); + // 注册应用生命周期监听 WidgetsBinding.instance.addObserver(this); // 🔧 新增:恢复上次选择的节点显示 _restoreSelectedNode(); + // ✅ 改进:不再在启动时检查权限,避免弹窗 + // 权限检查延迟到用户尝试切换全局模式时进行 + // 延迟同步连接状态,确保状态正确 Future.delayed(const Duration(milliseconds: 500), () { kr_forceSyncConnectionStatus(true); @@ -327,6 +343,10 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { _startStatusWatchdog(); } + /// ✅ Windows 权限检查已移至 kr_updateConnectionType 方法 + /// 当用户尝试切换到全局模式(TUN)时进行权限检查 + /// 这样不会在启动时弹窗,只在用户需要时提示 + /// 🔧 新增:恢复上次选择的节点显示 Future _restoreSelectedNode() async { try { @@ -593,7 +613,6 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { kr_currentViewStatus.value = KRHomeViewsStatus.kr_loggedIn; KRLogUtil.kr_i('登录状态变化:设置为已登录', tag: 'HomeController'); - KRAnnouncementService().kr_checkAnnouncement(); // 订阅服务已在 splash 页面初始化,此处无需重复初始化 KRLogUtil.kr_i('订阅服务已在启动页初始化,跳过重复初始化', tag: 'HomeController'); @@ -748,7 +767,7 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { _lastStatusChangeTime = DateTime.now(); switch (status) { - case SingboxStopped(): + case SingboxStopped(:final alert, :final message): KRLogUtil.kr_i('🔴 状态: 已停止', tag: 'HomeController'); kr_connectText.value = AppTranslations.kr_home.disconnected; kr_stopConnectionTimer(); @@ -758,12 +777,17 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { kr_currentSpeed.value = "--"; kr_isLatency.value = false; kr_isConnected.value = false; + // ✅ 关键修复:kr_isToggleBusy 已在 .then() 中释放,这里作为双重保险 + kr_isToggleBusy.value = false; kr_currentNodeLatency.value = -2; // 强制刷新 isConnected 状态 kr_isConnected.refresh(); + _maybeShowStoppedError(alert: alert, message: message); break; case SingboxStarting(): KRLogUtil.kr_i('🟡 状态: 正在启动', tag: 'HomeController'); + // 新一次连接流程开始,允许再次弹出 stopped 错误提示 + _lastStoppedErrorSignature = null; kr_connectText.value = AppTranslations.kr_home.connecting; kr_currentSpeed.value = AppTranslations.kr_home.connecting; kr_currentNodeLatency.value = -1; @@ -773,7 +797,6 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { break; case SingboxStarted(): KRLogUtil.kr_i('🟢 状态: 已启动', tag: 'HomeController'); - if (kDebugMode) {} // 取消连接超时处理 _cancelConnectionTimeout(); @@ -782,13 +805,13 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { kr_updateConnectionInfo(); kr_isLatency.value = false; kr_isConnected.value = true; + // ✅ 关键修复:kr_isToggleBusy 已在 .then() 中释放,这里作为双重保险 + kr_isToggleBusy.value = false; // 🔧 关键修复:如果延迟还是-1(连接中状态),立即设置为0(已连接但延迟未知) if (kr_currentNodeLatency.value == -1) { - if (kDebugMode) {} kr_currentNodeLatency.value = 0; kr_currentNodeLatency.refresh(); // 强制刷新延迟值 - if (kDebugMode) {} } // 🔧 修复:立即尝试更新延迟值 @@ -798,7 +821,6 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { kr_isConnected.refresh(); // 强制更新UI update(); - if (kDebugMode) {} break; case SingboxStopping(): KRLogUtil.kr_i('🟠 状态: 正在停止', tag: 'HomeController'); @@ -883,10 +905,89 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { }); } + /// ✅ 参考 Hiddify 的自动重连机制(改进版) + /// 当用户改变连接模式(全局/规则)时,自动重新连接以应用新配置 + /// 重要:添加了超时保护和权限检查,避免TUN权限问题导致卡顿 + bool _isReconnecting = false; // 防止并发重启 + KRConnectionType? _lastSuccessfulMode; // 记录上一次成功的模式,用于检测无限循环 + + void _setupConnectionModeListener() { + ever(KRSingBoxImp.instance.kr_connectionType, (newType) async { + KRLogUtil.kr_i('🔄 连接类型改变: $newType(前: $_lastSuccessfulMode)', tag: 'HomeController'); + + // 只有在已连接的情况下才重新连接 + if (!kr_isConnected.value) { + KRLogUtil.kr_i('ℹ️ VPN未连接,仅更新配置,无需重新连接', tag: 'HomeController'); + _lastSuccessfulMode = newType; // 更新记录 + return; + } + + // ❌ 关键修复:检测回滚导致的重复触发 + // 如果新模式是最后一次失败前的模式,说明是 kr_updateConnectionType 中的回滚,不再重试 + if (_lastSuccessfulMode != null && newType == _lastSuccessfulMode) { + KRLogUtil.kr_w('⚠️ 检测到回滚操作,不再重试(newType=$newType,lastSuccessful=$_lastSuccessfulMode)', tag: 'HomeController'); + return; + } + + // 防止并发重启 + if (_isReconnecting) { + KRLogUtil.kr_w('⚠️ 已有重连操作进行中,忽略此次切换', tag: 'HomeController'); + return; + } + + _isReconnecting = true; + + try { + KRLogUtil.kr_i('✅ VPN已连接,正在应用新的连接模式...', tag: 'HomeController'); + KRCommonUtil.kr_showToast('正在切换连接模式...'); + + // 添加超时保护(10秒),避免卡顿 + await KRSingBoxImp.instance.kr_restart().timeout( + const Duration(seconds: 10), + onTimeout: () { + KRLogUtil.kr_w('⏱️ 重连超时(10s),可能缺少权限', tag: 'HomeController'); + throw TimeoutException('连接模式切换超时,请检查是否需要管理员权限'); + }, + ); + + _lastSuccessfulMode = newType; // ✅ 切换成功,更新记录 + KRLogUtil.kr_i('✅ 连接模式切换完成', tag: 'HomeController'); + KRCommonUtil.kr_showToast('连接模式已切换'); + + } catch (e) { + KRLogUtil.kr_e('❌ 连接模式切换失败: $e', tag: 'HomeController'); + + // 根据错误类型显示不同的提示 + final errorMsg = e.toString(); + if (errorMsg.contains('超时') || errorMsg.contains('权限') || errorMsg.contains('admin') || errorMsg.contains('Permission')) { + KRCommonUtil.kr_showToast('全局模式需要管理员权限,请以管理员身份运行应用'); + } else { + KRCommonUtil.kr_showToast('切换失败: ${e.toString().split('\n').first}'); + } + } finally { + _isReconnecting = false; + } + }); + } + + /// 🔧 重构: 参考 hiddify-app 的 toggleConnection 实现 Future kr_toggleSwitch(bool value) async { final currentStatus = KRSingBoxImp.instance.kr_status.value; + if (kr_isToggleBusy.value) { + KRLogUtil.kr_i('toggleSwitch ignored: toggle is busy', tag: 'HomeController'); + return; + } + + final now = DateTime.now(); + if (_lastToggleRequestTime != null && + now.difference(_lastToggleRequestTime!) < _toggleDebounceWindow) { + KRLogUtil.kr_i('toggleSwitch debounced: ignore rapid taps', tag: 'HomeController'); + return; + } + _lastToggleRequestTime = now; + KRLogUtil.kr_i( '🔵 toggleSwitch 被调用: value=$value, currentStatus=$currentStatus', tag: 'HomeController'); @@ -910,100 +1011,91 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { return; } } + kr_isToggleBusy.value = true; + // 🔧 保守修复: 记录状态变化时间 + _lastStatusChangeTime = DateTime.now(); - try { - // 🔧 保守修复: 记录状态变化时间 - _lastStatusChangeTime = DateTime.now(); - - if (value) { - // 开启连接 - KRLogUtil.kr_i('🔄 开始连接...', tag: 'HomeController'); - if (kDebugMode) {} - await _kr_prepareCountrySelectionBeforeStart(); - final selectedAfter = - await KRSecureStorage().kr_readData(key: 'SELECTED_NODE_TAG'); - // KRLogUtil.kr_i('准备后 SELECTED_NODE_TAG: ${selectedAfter ?? ''}', - // tag: 'HomeController'); - // KRLogUtil.kr_i('准备后 kr_currentNodeName: ${kr_currentNodeName.value}', - // tag: 'HomeController'); - // KRLogUtil.kr_i('准备后 kr_cutTag: ${kr_cutTag.value}', - // tag: 'HomeController'); - // KRLogUtil.kr_i('准备后 kr_cutSeletedTag: ${kr_cutSeletedTag.value}', - // tag: 'HomeController'); - await kr_performNodeSwitch(selectedAfter!); - await KRSingBoxImp.instance.kr_start(); - KRLogUtil.kr_i('✅ 连接命令已发送', tag: 'HomeController'); - if (kDebugMode) {} - - // 🔧 修复: 等待状态更新,最多3秒 - await _waitForStatus(SingboxStatus.started().runtimeType, - maxSeconds: 3); - } else { - // 关闭连接 - KRLogUtil.kr_i('🛑 开始断开连接...', tag: 'HomeController'); - if (kDebugMode) {} - await KRSingBoxImp.instance.kr_stop().timeout( - const Duration(seconds: 10), - onTimeout: () { - KRLogUtil.kr_e('⚠️ 停止操作超时', tag: 'HomeController'); - throw TimeoutException('Stop operation timeout'); - }, - ); - KRLogUtil.kr_i('✅ 断开命令已发送', tag: 'HomeController'); - if (kDebugMode) {} - - // 🔧 保守修复: 等待状态更新,增加超时处理 - final success = await _waitForStatus( - SingboxStatus.stopped().runtimeType, - maxSeconds: 3, - ); - if (!success) { - // 停止超时,强制同步状态 - KRLogUtil.kr_w('⚠️ VPN 停止超时(3秒),强制同步状态', tag: 'HomeController'); - kr_forceSyncConnectionStatus(); - } - } - } catch (e) { - KRLogUtil.kr_e('❌ 切换失败: $e', tag: 'HomeController'); - if (kDebugMode) {} - // 发生错误时强制同步状态 - kr_forceSyncConnectionStatus(); - } - - if (kDebugMode) {} - } - - /// 🔧 等待状态达到预期值 - /// 🔧 保守修复: 返回 bool 表示是否成功达到预期状态 - Future _waitForStatus(Type expectedType, {int maxSeconds = 3}) async { - if (kDebugMode) {} - final startTime = DateTime.now(); - - while (DateTime.now().difference(startTime).inSeconds < maxSeconds) { - final currentStatus = KRSingBoxImp.instance.kr_status.value; - if (kDebugMode) {} - - if (currentStatus.runtimeType == expectedType) { - if (kDebugMode) {} - // 强制同步确保 kr_isConnected 正确 + if (value) { + // 开启连接 + KRLogUtil.kr_i('🔄 开始连接...', tag: 'HomeController'); + await _kr_prepareCountrySelectionBeforeStart(); + final selectedAfter = + await KRSecureStorage().kr_readData(key: 'SELECTED_NODE_TAG'); + // KRLogUtil.kr_i('准备后 SELECTED_NODE_TAG: ${selectedAfter ?? ''}', + // tag: 'HomeController'); + // KRLogUtil.kr_i('准备后 kr_currentNodeName: ${kr_currentNodeName.value}', + // tag: 'HomeController'); + // KRLogUtil.kr_i('准备后 kr_cutTag: ${kr_cutTag.value}', + // tag: 'HomeController'); + // KRLogUtil.kr_i('准备后 kr_cutSeletedTag: ${kr_cutSeletedTag.value}', + // tag: 'HomeController'); + await kr_performNodeSwitch(selectedAfter!); + // ✅ 关键修复:启动 VPN 时异步执行,使用 .then().catchError() 处理结果 + // 这样可以立即返回,不阻塞 UI 线程,同时能捕获异常 + KRSingBoxImp.instance.kr_start().then((_) { + KRLogUtil.kr_i('✅ 启动命令已返回(可能还在进行中)', tag: 'HomeController'); + // ✅ 关键修复:立即释放 UI 加载状态,不等待 status stream + // 这样用户可以立即看到按钮状态,不用等待 2-4 秒 + // 真实的连接状态由 status stream 驱动 + kr_isToggleBusy.value = false; + }).catchError((e) { + // ✅ 异步启动失败时立即释放锁并提示错误 + KRLogUtil.kr_e('❌ 启动 VPN 失败: $e', tag: 'HomeController'); + kr_isToggleBusy.value = false; kr_forceSyncConnectionStatus(); - // 🔧 更新状态变化时间 - _lastStatusChangeTime = DateTime.now(); - return true; // 成功 - } - await Future.delayed(const Duration(milliseconds: 100)); + Get.snackbar( + '加载失败', + '切换 VPN 状态时出错${ e.toString()}', + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 3), + ); + }); + + KRLogUtil.kr_i('✅ 连接命令已发送(异步执行)', tag: 'HomeController'); + + // ✅ 超时保护:即使启动失败也会通过 catchError 释放,这里是双重保险 + Future.delayed(const Duration(seconds: 30)).then((_) { + if (kr_isToggleBusy.value) { + KRLogUtil.kr_w('⚠️ 启动超时 30 秒,强制释放开关锁', tag: 'HomeController'); + kr_isToggleBusy.value = false; + } + }); + } else { + // 关闭连接 + KRLogUtil.kr_i('🛑 开始断开连接...', tag: 'HomeController'); + + // ✅ 关键修复:停止 VPN 时异步执行,使用 .then().catchError() 处理结果 + KRSingBoxImp.instance.kr_stop().then((_) { + KRLogUtil.kr_i('✅ 停止命令已返回(DNS/proxy 恢复可能还在进行中)', tag: 'HomeController'); + // ✅ 关键修复:立即释放 UI 加载状态,不等待 status stream + // Windows 上 DNS 恢复和 proxy 恢复会耗时 2-4 秒,但这些都是后台异步的 + // 用户应该立即看到按钮可用,而不用等待系统操作完成 + kr_isToggleBusy.value = false; + }).catchError((e) { + // ✅ 异步停止失败时立即释放锁并提示错误 + KRLogUtil.kr_e('❌ 停止 VPN 失败: $e', tag: 'HomeController'); + kr_isToggleBusy.value = false; + kr_forceSyncConnectionStatus(); + + Get.snackbar( + 'dialog.error'.tr, + 'home.toggleVpnStatusError'.trParams({'error': e.toString()}), + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 3), + ); + }); + + KRLogUtil.kr_i('✅ 断开命令已发送(异步执行)', tag: 'HomeController'); + + // ✅ 超时保护:即使停止失败也会通过 catchError 释放,这里是双重保险 + Future.delayed(const Duration(seconds: 15)).then((_) { + if (kr_isToggleBusy.value) { + KRLogUtil.kr_w('⚠️ 关闭超时 15 秒,强制释放开关锁', tag: 'HomeController'); + kr_isToggleBusy.value = false; + } + }); } - - // 🔧 保守修复: 超时后记录详细日志 - final finalStatus = KRSingBoxImp.instance.kr_status.value; - KRLogUtil.kr_w( - '⏱️ 等待状态超时: 期望=${expectedType.toString()}, 实际=${finalStatus.runtimeType}, 耗时=${maxSeconds}秒', - tag: 'HomeController', - ); - if (kDebugMode) {} - kr_forceSyncConnectionStatus(); - return false; // 超时失败 } Future _kr_prepareCountrySelectionBeforeStart() async { @@ -1461,92 +1553,45 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { kr_currentNodeLatency.value = -1; kr_isLatency.value = true; // 显示加载动画 - // 🔧 保存新节点选择 - KRLogUtil.kr_i('💾 保存新节点选择: $tag', tag: 'HomeController'); - await KRSecureStorage() - .kr_saveData(key: 'SELECTED_NODE_TAG', value: tag); + // 🚀 方案 A:统一调用路径 + // 使用 KRSingBoxImp.kr_selectOutbound() 而不是直接调用 selectOutbound API + // 这样可以自动获得所有保护机制: + // - 重试逻辑(3次) + // - 节点验证 + // - 20秒定时器防止被 urltest 覆盖 + KRLogUtil.kr_i('🔄 [方案A] 调用 KRSingBoxImp.kr_selectOutbound()...', tag: 'HomeController'); - // 🚀 核心改进:使用 selectOutbound 进行热切换(参考 hiddify-app) - // 优势:不重启VPN,保持连接状态,切换瞬间完成,VPN开关不闪烁 - KRLogUtil.kr_i('🔄 [热切换] 调用 selectOutbound 切换节点...', - tag: 'HomeController'); - - // 🔧 关键修复:确定正确的 selector 组 tag - // selectOutbound(groupTag, outboundTag) - 第一个参数是组的tag,不是节点的tag - final activeGroups = KRSingBoxImp.instance.kr_activeGroups; - String selectorGroupTag = 'select'; // 默认值 - - // 查找 selector 类型的组 - for (var group in activeGroups) { - if (group.type == ProxyType.selector) { - selectorGroupTag = group.tag; - KRLogUtil.kr_i('🔍 找到 selector 组: $selectorGroupTag', - tag: 'HomeController'); - break; - } - } - - KRLogUtil.kr_i('📡 调用 selectOutbound("$selectorGroupTag", "$tag")', - tag: 'HomeController'); - - // 调用 sing-box 的 selectOutbound API - final result = await KRSingBoxImp.instance.kr_singBox - .selectOutbound(selectorGroupTag, tag) - .run(); - - // 处理切换结果 - result.fold( - (error) { - // 切换失败 - KRLogUtil.kr_e('❌ selectOutbound 调用失败: $error', - tag: 'HomeController'); - throw Exception('节点切换失败: $error'); - }, - (_) { - // 切换成功 - KRSingBoxImp.instance.kr_startNodeSelectionMonitor(tag); - KRLogUtil.kr_i('✅ selectOutbound 调用成功', tag: 'HomeController'); - }, - ); - - // 后台切换成功,立即更新UI(乐观更新) + // 立即进行乐观更新,保持UI响应性 kr_cutSeletedTag.value = tag; kr_updateConnectionInfo(); - // kr_moveToSelectedNode(); + kr_moveToSelectedNode(); - // 🔧 短暂等待以确保内核状态同步(相比重启,等待时间大幅缩短) - KRLogUtil.kr_i('⏳ [热切换] 等待内核状态同步(200ms)...', tag: 'HomeController'); - await Future.delayed(const Duration(milliseconds: 200)); + // 异步执行节点选择和保护机制(包含定时器) + // 使用 .then() 避免阻塞UI + KRSingBoxImp.instance.kr_selectOutbound(tag).then((_) { + KRLogUtil.kr_i('✅ 节点热切换成功,已启动保护机制: $tag', tag: 'HomeController'); + // 更新延迟信息 + _kr_updateLatencyOnConnected(); + }).catchError((error) { + KRLogUtil.kr_e('❌ 节点切换失败: $error', tag: 'HomeController'); - // 🔍 验证节点是否真正切换成功 - KRLogUtil.kr_i('🔍 [验证] 检查节点切换结果...', tag: 'HomeController'); - try { - final updatedGroups = KRSingBoxImp.instance.kr_activeGroups; - final selectGroup = updatedGroups.firstWhere( - (group) => group.type == ProxyType.selector, - orElse: () => throw Exception('未找到 selector 组'), - ); + // 切换失败时恢复到原节点 + KRLogUtil.kr_i('🔄 节点选择失败,恢复到原节点: $originalTag', tag: 'HomeController'); + kr_cutTag.value = originalTag; + kr_currentNodeName.value = originalTag; + kr_cutSeletedTag.value = originalTag; - KRLogUtil.kr_i( - '📊 [验证] ${selectGroup.tag}组当前选中: ${selectGroup.selected}', - tag: 'HomeController'); - KRLogUtil.kr_i('📊 [验证] 目标节点: $tag', tag: 'HomeController'); - - if (selectGroup.selected != tag) { - KRLogUtil.kr_w('⚠️ [验证] 节点选择验证失败,实际选中: ${selectGroup.selected}', - tag: 'HomeController'); - // 不抛出异常,但记录警告 - } else { - KRLogUtil.kr_i('✅ [验证] 节点选择验证成功!', tag: 'HomeController'); + // 尝试将存储中的节点恢复为原节点 + try { + KRSecureStorage().kr_saveData(key: 'SELECTED_NODE_TAG', value: originalTag); + } catch (e) { + KRLogUtil.kr_e('❌ 恢复节点选择失败: $e', tag: 'HomeController'); } - } catch (e) { - KRLogUtil.kr_w('⚠️ [验证] 节点验证过程出错: $e', tag: 'HomeController'); - } - // 更新延迟信息 - _kr_updateLatencyOnConnected(); + KRCommonUtil.kr_showToast('节点切换失败,已恢复为: $originalTag'); + }); - KRLogUtil.kr_i('✅ 节点热切换成功,VPN保持连接: $tag', tag: 'HomeController'); + KRLogUtil.kr_i('✅ 节点热切换请求已发送,VPN保持连接: $tag', tag: 'HomeController'); return true; } catch (switchError) { // 后台切换失败,恢复到原节点 @@ -2556,11 +2601,55 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { } } } + // 🔧 新增:检查订阅是否过期,如果过期则重新拉取 + _kr_checkAndRefreshExpiredSubscribe(); }); KRLogUtil.kr_i('✅ 状态监控定时器已启动', tag: 'HomeController'); } + /// 🔧 新增:检查订阅是否过期,若过期则自动重新拉取 + void _kr_checkAndRefreshExpiredSubscribe() { + try { + final currentSubscribe = kr_subscribeService.kr_currentSubscribe.value; + if (currentSubscribe == null) { + // 当前无订阅,可能已过期或未设置,尝试重新拉取 + KRLogUtil.kr_w('⚠️ 检测到无有效订阅,准备重新拉取订阅数据', tag: 'HomeController'); + kr_refreshAll(); + return; + } + + // 检查订阅是否过期 + if (currentSubscribe.expireTime.isEmpty) { + return; // 无过期时间,不处理 + } + + try { + final expireTime = DateTime.parse(currentSubscribe.expireTime); + // 特殊处理 1970 年的情况(永久有效) + if (expireTime.year == 1970) { + return; // 永久有效,不处理 + } + + final now = DateTime.now(); + final isExpired = !expireTime.isAfter(now); + + if (isExpired) { + KRLogUtil.kr_w( + '⚠️ 检测到订阅已过期: ${currentSubscribe.name}, expire=${currentSubscribe.expireTime}', + tag: 'HomeController', + ); + // 订阅已过期,重新拉取最新的订阅数据 + kr_refreshAll(); + } + } catch (e) { + KRLogUtil.kr_d('解析过期时间失败: $e', tag: 'HomeController'); + } + } catch (e) { + KRLogUtil.kr_d('订阅过期检查异常: $e', tag: 'HomeController'); + } + } + /// 连接超时处理 Timer? _connectionTimeoutTimer; @@ -2591,6 +2680,46 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { }); } + + void _maybeShowStoppedError({SingboxAlert? alert, String? message}) { + final messageText = message?.trim(); + if (alert == null && (messageText == null || messageText.isEmpty)) return; + + final signature = '${alert?.name ?? ""}|${messageText ?? ""}'; + if (signature == _lastStoppedErrorSignature) return; + _lastStoppedErrorSignature = signature; + + final detail = _presentSingboxStoppedReason(alert: alert, message: messageText); + Get.snackbar( + 'network.status.failed'.tr, + detail, + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 4), + ); + } + + String _presentSingboxStoppedReason({required SingboxAlert? alert, String? message}) { + final parts = []; + if (alert != null) { + parts.add(_presentSingboxAlert(alert)); + } + if (message != null && message.trim().isNotEmpty) { + parts.add(message.trim()); + } + return parts.join('\n'); + } + + String _presentSingboxAlert(SingboxAlert alert) { + return switch (alert) { + SingboxAlert.requestVPNPermission => 'failure.connectivity.missingVpnPermission'.tr, + SingboxAlert.requestNotificationPermission => 'failure.connectivity.missingNotificationPermission'.tr, + SingboxAlert.emptyConfiguration => 'failure.singbox.invalidConfig'.tr, + SingboxAlert.startCommandServer => 'failure.singbox.start'.tr, + SingboxAlert.createService => 'failure.singbox.create'.tr, + SingboxAlert.startService => 'failure.singbox.start'.tr, + }; + } + void _cancelConnectionTimeout() { _connectionTimeoutTimer?.cancel(); _connectionTimeoutTimer = null; @@ -2660,7 +2789,7 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { return delays; } - /// 尝试从活动组更新延迟值 + /// 尝试从活动组更新延迟值(优化版:reduce loops and logs) bool _kr_tryUpdateDelayFromActiveGroups() { try { final activeGroups = KRSingBoxImp.instance.kr_activeGroups; @@ -2670,19 +2799,30 @@ class KRHomeController extends GetxController with WidgetsBindingObserver { return false; } - // 查找 selector 类型的组 + // 确定目标节点(auto 或手动选择的) + final targetTag = kr_cutTag.value == "auto" ? "auto" : kr_cutTag.value; + + // 查找 selector 类型的组并提前退出 for (var group in activeGroups) { if (group.type == ProxyType.selector) { - KRLogUtil.kr_d('找到 selector 组: ${group.tag}, 选中: ${group.selected}', - tag: 'HomeController'); + try { + // 使用 firstWhere 替代嵌套循环,找到匹配项后立即返回 + final item = group.items.firstWhere( + (item) => item.tag == targetTag && item.urlTestDelay != 0, + ); - for (var item in group.items) { - if (item.tag == kr_cutTag.value && item.urlTestDelay != 0) { - kr_currentNodeLatency.value = item.urlTestDelay; - KRLogUtil.kr_i('✅ 延迟值: ${item.urlTestDelay}ms', - tag: 'HomeController'); - return true; + kr_currentNodeLatency.value = item.urlTestDelay; + + if (kDebugMode) { + KRLogUtil.kr_d( + '✅ 延迟更新: $targetTag = ${item.urlTestDelay}ms', + tag: 'HomeController', + ); } + return true; // 找到后立即返回 + } catch (_) { + // 未找到匹配项,继续查找下一个组 + continue; } } } diff --git a/lib/app/network/http_util.dart b/lib/app/network/http_util.dart index feae3b5..b3fc2aa 100755 --- a/lib/app/network/http_util.dart +++ b/lib/app/network/http_util.dart @@ -85,8 +85,8 @@ class HttpUtil { client.findProxy = (url) { try { // 检查 SingBox 是否正在运行 - final singBoxStatus = KRSingBoxImp.instance.kr_status; - final isProxyAvailable = singBoxStatus == SingboxStatus.started(); + final singBoxStatus = KRSingBoxImp.instance.kr_status.value; + final isProxyAvailable = singBoxStatus is SingboxStarted;; if (!isProxyAvailable) { // 代理未运行,直接使用直连 diff --git a/lib/app/services/kr_device_info_service.dart b/lib/app/services/kr_device_info_service.dart index 16144cb..50e6107 100644 --- a/lib/app/services/kr_device_info_service.dart +++ b/lib/app/services/kr_device_info_service.dart @@ -225,17 +225,21 @@ class KRDeviceInfoService { } /// Windows设备ID - 使用机器GUID + /// 🔧 修复:不使用 flutter_udid,因为它会调用 wmic 命令弹出黑窗口 Future _getWindowsDeviceId() async { try { final windowsInfo = await _deviceInfo.windowsInfo; - // 优先使用 flutter_udid - String udid = await FlutterUdid.consistentUdid; + // 🔧 修复:不使用 FlutterUdid.consistentUdid + // 因为它在 Windows 上会调用 "cmd.exe /c wmic csproduct get UUID" + // 这个调用没有使用 CREATE_NO_WINDOW 标志,会弹出黑色命令行窗口 + + // 直接使用 device_info_plus 提供的信息构建唯一标识 + // windowsInfo.deviceId 已经是一个稳定的设备标识 // 构建多因子字符串 final factors = [ - udid, - windowsInfo.deviceId, // 设备ID + windowsInfo.deviceId, // 设备ID (最稳定,来自注册表 MachineGuid) windowsInfo.computerName, // 计算机名 windowsInfo.productName, // 产品名 windowsInfo.numberOfCores.toString(), // CPU核心数 diff --git a/lib/app/services/singbox_imp/kr_sing_box_imp.dart b/lib/app/services/singbox_imp/kr_sing_box_imp.dart index 8da42a8..87bb5d6 100755 --- a/lib/app/services/singbox_imp/kr_sing_box_imp.dart +++ b/lib/app/services/singbox_imp/kr_sing_box_imp.dart @@ -1,10 +1,14 @@ 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'; @@ -20,8 +24,10 @@ 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'; -import '../../utils/kr_windows_dns_util.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'; @@ -31,6 +37,19 @@ enum KRConnectionType { // 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._(); @@ -86,6 +105,9 @@ class KRSingBoxImp { /// 端口 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, @@ -99,8 +121,14 @@ class KRSingBoxImp { /// 活动的出站分组 RxList kr_activeGroups = [].obs; - // 🔒 Windows DNS 优化:标志位,记录 DNS 是否已备份 - bool _dnsBackedUp = false; + // ✅ 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 kr_allGroups = [].obs; @@ -108,13 +136,32 @@ class KRSingBoxImp { /// Stream 订阅管理器 final List> _kr_subscriptions = []; - StreamSubscription? _kr_statusSubscription; + // 🔧 P0-1 + P2-11: 改进订阅管理 - 使用 Map 而非 List,防止重复订阅 + // P2-11: hashCode 去重不可靠 - 改为使用类型安全的 Map,避免基于 hashCode.toString().contains() 的脆弱判断 + /// 精确的订阅管理(Map) + final Map> _subscriptionMap = {}; + + // ✅ 系统代理操作已移交给 Go 后端 (libcore.dll) 处理 + // 不再需要 Dart 层的代理操作互斥锁 /// 初始化标志,防止重复初始化 bool _kr_isInitialized = false; - /// 初始化进行中共享 Future(single-flight 防并发) - Future? _kr_initFuture; + // 🔧 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; @@ -124,8 +171,8 @@ class KRSingBoxImp { /// 构建 Dart HttpClient 可识别的代理规则字符串 /// /// 当 sing-box 尚未启动时返回 `DIRECT`,启动后返回 - /// `PROXY 127.0.0.1:; DIRECT`,以便在代理不可用时自动回落。 - String kr_buildProxyRule({bool includeDirectFallback = true}) { + /// `PROXY 127.0.0.1:`,可选附加 DIRECT 回落。 + String kr_buildProxyRule({bool includeDirectFallback = false}) { if (!kr_isProxyReady) { const directRule = 'DIRECT'; if (_lastProxyRule != directRule) { @@ -148,23 +195,12 @@ class KRSingBoxImp { /// 初始化 Future init() async { - // 防止重复初始化(已完成) + // 防止重复初始化 if (_kr_isInitialized) { KRLogUtil.kr_i('SingBox 已经初始化,跳过重复初始化', tag: 'SingBox'); return; } - // 防止并发重复初始化(进行中复用同一个 Future) - if (_kr_initFuture != null) { - KRLogUtil.kr_i('SingBox 初始化进行中,等待完成(single-flight)', tag: 'SingBox'); - await _kr_initFuture; - return; - } - - // 建立 single-flight 共享 Future - final completer = Completer(); - _kr_initFuture = completer.future; - try { KRLogUtil.kr_i('开始初始化 SingBox'); // 在应用启动时初始化 @@ -187,14 +223,14 @@ class KRSingBoxImp { 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), + 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; + Platform.isAndroid ? await getExternalStorageDirectory() : baseDir; final tempDir = await getTemporaryDirectory(); // Windows 路径规范化:确保使用正确的路径分隔符 @@ -202,8 +238,7 @@ class KRSingBoxImp { if (Platform.isWindows) { final normalized = dir.path.replaceAll('/', '\\'); if (normalized != dir.path) { - KRLogUtil.kr_i('路径规范化: ${dir.path} -> $normalized', - tag: 'SingBox'); + KRLogUtil.kr_i('路径规范化: ${dir.path} -> $normalized', tag: 'SingBox'); return Directory(normalized); } } @@ -211,17 +246,16 @@ class KRSingBoxImp { } kr_configDics = ( - baseDir: normalizePath(baseDir), - workingDir: normalizePath(workingDir!), - tempDir: normalizePath(tempDir), + 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('workingDir: ${kr_configDics.workingDir.path}', tag: 'SingBox'); KRLogUtil.kr_i('tempDir: ${kr_configDics.tempDir.path}', tag: 'SingBox'); // 确保所有目录都存在 @@ -257,14 +291,12 @@ class KRSingBoxImp { break; } else { retryCount++; - KRLogUtil.kr_i('⚠️ data 目录创建后验证失败,重试 $retryCount/$maxRetries', - tag: 'SingBox'); + 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'); + KRLogUtil.kr_e('❌ 创建 data 目录失败 (尝试 $retryCount/$maxRetries): $e', tag: 'SingBox'); if (retryCount >= maxRetries) { throw Exception('无法创建 libcore 数据库目录: ${dataDir.path},错误: $e'); } @@ -300,40 +332,32 @@ class KRSingBoxImp { // libcore 的 Setup() 会调用 os.Chdir(workingPath),然后使用相对路径 "./data" // 如果 os.Chdir() 失败(路径不存在或权限问题),后续的相对路径访问会失败 if (!kr_configDics.workingDir.existsSync()) { - final error = - '❌ workingDir 不存在,无法调用 setup(): ${kr_configDics.workingDir.path}'; + 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')); + 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'; + 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')); + 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'); + 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'); + 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'); } @@ -352,13 +376,11 @@ class KRSingBoxImp { // 不抛出异常,让 setup() 自己处理 } - final configsDir = - Directory(p.join(kr_configDics.workingDir.path, "configs")); + 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'); + KRLogUtil.kr_i('✅ 已创建 configs 目录: ${configsDir.path}', tag: 'SingBox'); } catch (e) { KRLogUtil.kr_e('⚠️ configs 目录创建失败: $e', tag: 'SingBox'); // 不抛出异常,继续初始化 @@ -370,10 +392,8 @@ class KRSingBoxImp { // 特别处理 extensionData.db 文件 (Windows特定) if (Platform.isWindows) { try { - final extensionDataDbPath = - p.join(finalDataDir.path, 'extensionData.db'); - KRLogUtil.kr_i('👉 准备处理 extensionData.db 路径: $extensionDataDbPath', - tag: 'SingBox'); + final extensionDataDbPath = p.join(finalDataDir.path, 'extensionData.db'); + KRLogUtil.kr_i('👉 准备处理 extensionData.db 路径: $extensionDataDbPath', tag: 'SingBox'); // 确保 extensionData.db 的父目录存在 final extensionDataParent = Directory(p.dirname(extensionDataDbPath)); @@ -383,8 +403,7 @@ class KRSingBoxImp { } // 测试文件创建权限 - final testFile = - File(p.join(extensionDataParent.path, '.test_extension')); + final testFile = File(p.join(extensionDataParent.path, '.test_extension')); await testFile.writeAsString('test'); await testFile.delete(); KRLogUtil.kr_i('✅ extensionData 目录权限验证通过', tag: 'SingBox'); @@ -396,26 +415,18 @@ class KRSingBoxImp { 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'); + 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'); + KRLogUtil.kr_i(' - data 目录现有文件: ${dataContents.map((e) => e.path.split(Platform.pathSeparator).last).join(", ")}', tag: 'SingBox'); } else { KRLogUtil.kr_i(' - data 目录为空', tag: 'SingBox'); } @@ -424,36 +435,37 @@ class KRSingBoxImp { } } - KRLogUtil.kr_i( - ' - libcore 将通过 os.Chdir() 切换到: ${kr_configDics.workingDir.path}', - 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'); + KRLogUtil.kr_e('⚠️ 警告:Windows 路径包含正斜杠,可能导致问题: $workingPath', tag: 'SingBox'); } // 确保路径使用反斜杠(Windows 标准) final normalizedPath = workingPath.replaceAll('/', '\\'); if (normalizedPath != workingPath) { - KRLogUtil.kr_e('⚠️ 路径格式可能需要规范化: $workingPath -> $normalizedPath', - tag: 'SingBox'); + 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) { + (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'); }, ); @@ -470,11 +482,8 @@ class KRSingBoxImp { // 🔑 关键:在初始化完成后立即订阅状态变化流 // 这样可以确保 UI 始终与 libcore 的实际状态同步 _kr_subscribeToStatus(); + // ✅ DNS 由 libcore.dll 自动管理,无需手动备份 KRLogUtil.kr_i('✅ 状态订阅已设置', tag: 'SingBox'); - - // 完成 single-flight - completer.complete(); - _kr_initFuture = null; } catch (e, stackTrace) { KRLogUtil.kr_e('❌ SingBox 初始化失败: $e'); KRLogUtil.kr_e('📚 错误堆栈: $stackTrace'); @@ -488,25 +497,19 @@ class KRSingBoxImp { 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(' - workingDir 存在: ${workingDir.existsSync()}', tag: 'SingBox'); KRLogUtil.kr_e(' - data 目录: ${dataDir.path}', tag: 'SingBox'); - KRLogUtil.kr_e(' - data 目录存在: ${dataDir.existsSync()}', - 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'); + 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'); + 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'); + KRLogUtil.kr_e(' - 无法列出 workingDir 内容: $listErr', tag: 'SingBox'); } } } catch (diagErr) { @@ -516,11 +519,6 @@ class KRSingBoxImp { // 如果初始化失败,允许下次重试 _kr_isInitialized = false; - // 失败时通知等待者并清理 single-flight - if (!completer.isCompleted) { - completer.completeError(e, stackTrace); - } - _kr_initFuture = null; rethrow; } } @@ -529,8 +527,7 @@ class KRSingBoxImp { Future _kr_extractGeositeFiles() async { try { // 创建 geosite 目录 - final geositeDir = - Directory(p.join(kr_configDics.workingDir.path, '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'); @@ -547,8 +544,7 @@ class KRSingBoxImp { // 检查文件是否已存在 if (targetFile.existsSync()) { final fileSize = await targetFile.length(); - KRLogUtil.kr_i('📄 $filename 已存在 (${fileSize} bytes), 跳过', - tag: 'SingBox'); + KRLogUtil.kr_i('📄 $filename 已存在 (${fileSize} bytes), 跳过', tag: 'SingBox'); continue; } @@ -561,8 +557,7 @@ class KRSingBoxImp { await targetFile.writeAsBytes(bytes); final writtenSize = await targetFile.length(); - KRLogUtil.kr_i('✅ 提取成功: $filename (${writtenSize} bytes)', - tag: 'SingBox'); + KRLogUtil.kr_i('✅ 提取成功: $filename (${writtenSize} bytes)', tag: 'SingBox'); } KRLogUtil.kr_i('🎉 所有 geosite 文件提取完成', tag: 'SingBox'); @@ -582,49 +577,65 @@ class KRSingBoxImp { // 🔧 关键修复:全局代理模式下,region 强制设为 'other',libcore 就不会生成国家直连规则 final String effectiveRegion; if (kr_connectionType.value == KRConnectionType.global) { - effectiveRegion = 'other'; // 全局代理:不添加任何国家规则 + effectiveRegion = 'other'; // 全局代理:不添加任何国家规则 KRLogUtil.kr_i('🌐 [全局代理模式] region 设为 other,所有流量走代理', tag: 'SingBox'); } else { - effectiveRegion = - KRCountryUtil.kr_getCurrentCountryCode(); // 智能代理:使用用户选择的国家 + 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: 默认关闭广告拦截 + "region": effectiveRegion, // 🔧 修复:根据出站模式动态设置 region + "block-ads": false, // 参考 hiddify-app: 默认关闭广告拦截 "use-xray-core-when-possible": false, - "execute-config-as-is": true, // 🔧 修复:使用我们生成的完整配置,不让 libcore 重写 - "log-level": "info", // 调试阶段使用 info,生产环境改为 warn + "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": - "https://dns.google/dns-query", // 使用 Google DoH,避免中转节点 DNS 死锁 - "remote-dns-domain-strategy": "prefer_ipv4", - "direct-dns-address": "local", // 使用系统 DNS,确保中转服务器域名能被解析 - "direct-dns-domain-strategy": "prefer_ipv4", + "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 测试端点 + "connection-test-url": "http://cp.cloudflare.com", // 参考 hiddify-app: 使用 Cloudflare 测试端点 "url-test-interval": 30, "enable-clash-api": true, "clash-api-port": 36756, - "enable-tun": Platform.isIOS || Platform.isAndroid, + // 🔧 关键改动:根据连接类型选择使用 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, + (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": true, + "enable-dns-routing": enableDnsRouting, "independent-dns-cache": true, - "rules": _kr_buildHiddifyRules(), + "rules": [ + // ✅ 自定义域名直连规则 - 添加到 HiddifyOptions.Rules 中 + // 🔧 修复: 空规则列表,避免 "missing conditions" 错误 + // 如果需要添加规则,必须确保所有必需字段都存在 + ], "mux": { "enable": false, "padding": false, @@ -672,14 +683,11 @@ class KRSingBoxImp { // 🔧 调试日志:确认自定义规则已添加 final rules = op["rules"] as List?; - KRLogUtil.kr_i('✅ HiddifyOptions 已生成,包含 ${rules?.length ?? 0} 条自定义路由规则', - tag: 'SingBox'); + KRLogUtil.kr_i('✅ HiddifyOptions 已生成,包含 ${rules?.length ?? 0} 条自定义路由规则', tag: 'SingBox'); if (rules != null && rules.isNotEmpty) { for (var rule in rules) { final ruleMap = rule as Map; - KRLogUtil.kr_i( - ' - 规则: domains=${ruleMap["domains"]}, outbound=${ruleMap["outbound"]}', - tag: 'SingBox'); + KRLogUtil.kr_i(' - 规则: domains=${ruleMap["domains"]}, outbound=${ruleMap["outbound"]}', tag: 'SingBox'); } } @@ -734,39 +742,55 @@ class KRSingBoxImp { /// 参考 hiddify-app: 监听 libcore 发送的状态事件来自动更新 UI void _kr_subscribeToStatus() { if (kDebugMode) { - print('🔵 _kr_subscribeToStatus 被调用,重新订阅状态流'); + KRFileLogger.log('[黑屏调试] 🔵 _kr_subscribeToStatus 被调用,重新订阅状态流'); } KRLogUtil.kr_i('🔵 _kr_subscribeToStatus 被调用', tag: 'SingBox'); - _kr_statusSubscription?.cancel(); - _kr_statusSubscription = kr_singBox.watchStatus().listen( - (status) { - if (status == kr_status.value) { - return; - } + // 取消之前的状态订阅 + for (var sub in _kr_subscriptions) { + if (sub.hashCode.toString().contains('Status')) { + sub.cancel(); if (kDebugMode) { - print('🔵 收到 Native 状态更新: ${status.runtimeType}'); + KRFileLogger.log('[黑屏调试] 🔵 已取消旧的状态订阅'); } - KRLogUtil.kr_i('📡 收到状态更新: $status', tag: 'SingBox'); - kr_status.value = status; - }, - onError: (error) { - if (kDebugMode) { - print('🔵 状态流错误: $error'); - } - KRLogUtil.kr_e('📡 状态流错误: $error', tag: 'SingBox'); - }, - cancelOnError: false, + } + } + _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) { - print('🔵 状态流订阅完成'); + KRFileLogger.log('[黑屏调试] 🔵 状态流订阅完成'); } } /// 订阅统计数据流 - void _kr_subscribeToStats() { - // 取消之前的统计订阅 + /// 🔧 UI阻塞修复:改为异步方法,在FFI调用前让出控制权 + Future _kr_subscribeToStats() async { + // 🔧 P0-1: 先取消旧的 stats 订阅 + _subscriptionMap[_SubscriptionType.stats]?.cancel(); + + // 移除列表中的旧订阅(向后兼容) for (var sub in _kr_subscriptions) { if (sub.hashCode.toString().contains('Stats')) { sub.cancel(); @@ -775,12 +799,15 @@ class KRSingBoxImp { _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) { + (stats) { kr_stats.value = stats; }, onError: (error) { @@ -788,13 +815,23 @@ class KRSingBoxImp { }, cancelOnError: false, ); + + // 🔧 P0-1: 使用 Map 精确管理订阅 + _subscriptionMap[_SubscriptionType.stats] = subscription; _kr_subscriptions.add(subscription); + + KRLogUtil.kr_i('✅ Stats 订阅已建立', tag: 'SingBox'); } /// 订阅分组数据流 - void _kr_subscribeToGroups() { - print('[_kr_subscribeToGroups] 🚀 开始订阅分组数据流'); - // 取消之前的分组订阅 + /// 🔧 UI阻塞修复:改为异步方法,在FFI调用前让出控制权 + Future _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(); @@ -803,115 +840,74 @@ class KRSingBoxImp { _kr_subscriptions .removeWhere((sub) => sub.hashCode.toString().contains('Groups')); - _kr_subscriptions.add( - kr_singBox.watchActiveGroups().listen( - (groups) { - print('[watchActiveGroups] 📡 收到活动组更新,数量: ${groups.length}'); - KRLogUtil.kr_i('📡 收到活动组更新,数量: ${groups.length}', tag: 'SingBox'); - kr_activeGroups.value = groups; + // 🔧 关键修复:在调用同步FFI之前让出控制权,给UI一帧渲染时间 + await Future.delayed(Duration.zero); - // 详细打印每个组的信息 - 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'); - } + // 订阅活动分组 + 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) { - print('[watchActiveGroups] ❌ 活动分组监听错误: $error'); - KRLogUtil.kr_e('❌ 活动分组监听错误: $error', tag: 'SingBox'); - }, - cancelOnError: false, - ), - ); - - _kr_subscriptions.add( - kr_singBox.watchGroups().listen( - (groups) { - print('[watchGroups] 📡 收到所有组更新,数量: ${groups.length}'); - kr_allGroups.value = groups; - // 打印每个组的基本信息 - for (int i = 0; i < groups.length; i++) { - final group = groups[i]; - print( - '[watchGroups] 组[$i]: tag=${group.tag}, type=${group.type}, selected=${group.selected}'); - } - }, - onError: (error) { - print('[watchGroups] ❌ 所有分组监听错误: $error'); - KRLogUtil.kr_e('所有分组监听错误: $error'); - }, - cancelOnError: false, - ), - ); - print( - '[_kr_subscribeToGroups] ✅ 分组数据流订阅完成,当前订阅数: ${_kr_subscriptions.length}'); - } - - /// 验证节点选择是否生效 - /// - /// 检查活动组中 "select" 组的 selected 字段是否是目标节点 - Future _kr_verifyNodeSelection(String targetTag) async { - try { - KRLogUtil.kr_i('🔍 开始验证节点选择: $targetTag', tag: 'SingBox'); - - // 查找 "select" 组 - final selectGroup = kr_activeGroups.firstWhere( - (group) => group.tag == 'select', - orElse: () => throw Exception('未找到 "select" 选择器组'), - ); - - KRLogUtil.kr_i('📊 Select 组状态:', tag: 'SingBox'); - KRLogUtil.kr_i(' - 组标签: ${selectGroup.tag}', tag: 'SingBox'); - KRLogUtil.kr_i(' - 组类型: ${selectGroup.type}', tag: 'SingBox'); - KRLogUtil.kr_i(' - 当前选中: ${selectGroup.selected}', tag: 'SingBox'); - KRLogUtil.kr_i(' - 目标节点: $targetTag', tag: 'SingBox'); - KRLogUtil.kr_i(' - 可用节点数: ${selectGroup.items.length}', tag: 'SingBox'); - - // 验证目标节点是否在可用列表中 - final hasTarget = selectGroup.items.any((item) => item.tag == targetTag); - if (!hasTarget) { - KRLogUtil.kr_w('⚠️ 目标节点不在 select 组的可用列表中: $targetTag', tag: 'SingBox'); - KRLogUtil.kr_w('可用节点列表:', tag: 'SingBox'); - for (var item in selectGroup.items) { - KRLogUtil.kr_w(' - ${item.tag}', tag: 'SingBox'); } - } - // 检查是否切换成功 - if (selectGroup.selected != targetTag) { - KRLogUtil.kr_e('❌ 节点切换验证失败!', tag: 'SingBox'); - KRLogUtil.kr_e(' - 期望: $targetTag', tag: 'SingBox'); - KRLogUtil.kr_e(' - 实际: ${selectGroup.selected}', tag: 'SingBox'); - throw Exception('节点切换失败:实际选中 ${selectGroup.selected},期望 $targetTag'); - } + KRLogUtil.kr_i('✅ 活动组处理完成', tag: 'SingBox'); + }, + onError: (error) { + KRFileLogger.log('[黑屏调试] [watchActiveGroups] ❌ 活动分组监听错误: $error'); + KRLogUtil.kr_e('❌ 活动分组监听错误: $error', tag: 'SingBox'); + }, + cancelOnError: false, + ); + _kr_subscriptions.add(activeGroupsSubscription); - KRLogUtil.kr_i('✅ 节点切换验证成功: ${selectGroup.selected} == $targetTag', - tag: 'SingBox'); - } catch (e) { - KRLogUtil.kr_e('❌ 节点验证异常: $e', tag: 'SingBox'); - // 不抛出异常,只记录日志,避免阻塞流程 - } + // 🔧 关键修复:在两个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 _kr_selectOutboundWithRetry( - String groupTag, - String outboundTag, { - int maxAttempts = 3, - int initialDelay = 100, - }) async { + String groupTag, + String outboundTag, { + int maxAttempts = 3, + int initialDelay = 100, + }) async { int attempt = 0; int delay = initialDelay; @@ -974,8 +970,7 @@ class KRSingBoxImp { try { // 先验证 command.sock 是否可访问 - final socketFile = - File(p.join(kr_configDics.baseDir.path, 'command.sock')); + final socketFile = File(p.join(kr_configDics.baseDir.path, 'command.sock')); KRLogUtil.kr_i('🔍 检查 socket 文件: ${socketFile.path}', tag: 'SingBox'); if (!socketFile.existsSync()) { @@ -989,12 +984,10 @@ class KRSingBoxImp { bool statsSubscribed = false; bool groupsSubscribed = false; - // ⚠️ 关键修复:使用 Future.delayed(Duration.zero) 将订阅推到下一个事件循环 - // 这样可以避免阻塞当前的异步执行 + // 🔧 UI阻塞修复:订阅方法现在是异步的,会在FFI调用前让出控制权 try { KRLogUtil.kr_i('📊 订阅统计数据流...', tag: 'SingBox'); - await Future.delayed(Duration.zero); // 让出 UI 线程 - _kr_subscribeToStats(); + await _kr_subscribeToStats(); // 🔧 修复:使用await调用异步方法 statsSubscribed = true; KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox'); } catch (e) { @@ -1003,8 +996,7 @@ class KRSingBoxImp { try { KRLogUtil.kr_i('📋 订阅分组数据流...', tag: 'SingBox'); - await Future.delayed(Duration.zero); // 让出 UI 线程 - _kr_subscribeToGroups(); + await _kr_subscribeToGroups(); // 🔧 修复:使用await调用异步方法 groupsSubscribed = true; KRLogUtil.kr_i('✅ 分组数据流订阅成功', tag: 'SingBox'); } catch (e) { @@ -1025,8 +1017,7 @@ class KRSingBoxImp { throw Exception('订阅列表为空,command client 未成功连接'); } - KRLogUtil.kr_i('✅ 命令客户端初始化成功,活跃订阅数: ${_kr_subscriptions.length}', - tag: 'SingBox'); + KRLogUtil.kr_i('✅ 命令客户端初始化成功,活跃订阅数: ${_kr_subscriptions.length}', tag: 'SingBox'); return; } catch (e, stackTrace) { // 详细记录失败原因和堆栈信息 @@ -1069,8 +1060,7 @@ class KRSingBoxImp { await Future.delayed(const Duration(milliseconds: 1500)); // 检查 socket 文件 - final socketFile = - File(p.join(kr_configDics.baseDir.path, 'command.sock')); + final socketFile = File(p.join(kr_configDics.baseDir.path, 'command.sock')); if (!socketFile.existsSync()) { KRLogUtil.kr_w('⚠️ command.sock 尚未创建,预连接取消', tag: 'SingBox'); return; @@ -1078,21 +1068,22 @@ class KRSingBoxImp { KRLogUtil.kr_i('✅ command.sock 已就绪,尝试预订阅...', tag: 'SingBox'); - // ⚠️ 注意:这里只是"触发"订阅,不等待结果 + // 🔧 UI阻塞修复:订阅方法现在是异步的,会在FFI调用前让出控制权 // 如果失败,UI 调用时会重新尝试 try { - _kr_subscribeToStats(); + await _kr_subscribeToStats(); // 🔧 修复:使用await KRLogUtil.kr_i('✅ 统计流预订阅成功', tag: 'SingBox'); } catch (e) { KRLogUtil.kr_w('⚠️ 统计流预订阅失败(正常,UI 调用时会重试): $e', tag: 'SingBox'); } try { - _kr_subscribeToGroups(); + 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'); @@ -1104,31 +1095,49 @@ class KRSingBoxImp { /// /// 借鉴 Hiddify:watchGroups() 会在首次调用时自动 startCommandClient() Future _kr_ensureCommandClientInitialized() async { - // 如果已经有订阅,说明 command client 已初始化 - if (_kr_subscriptions.isNotEmpty) { - KRLogUtil.kr_i('✅ Command client 已初始化(订阅数: ${_kr_subscriptions.length})', - tag: 'SingBox'); + // 🔧 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'); + KRLogUtil.kr_i('⚠️ Command client 未初始化或订阅丢失,重新初始化...', tag: 'SingBox'); - try { - // 触发 watchGroups(),这会自动调用 startCommandClient() - _kr_subscribeToGroups(); + // 🔧 P1-3: 使用 Mutex 保护初始化过程,防止并发调用导致重复初始化(内存泄漏) + await _commandClientInitLock.synchronized(() async { + try { + // 再次检查(double-check locking 模式,避免在等待锁期间被其他线程初始化) + final hasGroupsSubscription = _subscriptionMap[_SubscriptionType.groups] != null; + final hasStatsSubscription = _subscriptionMap[_SubscriptionType.stats] != null; - // 等待一小段时间让订阅建立 - await Future.delayed(const Duration(milliseconds: 300)); + if (hasGroupsSubscription && hasStatsSubscription) { + KRLogUtil.kr_i('✅ Command client 在等待期间已被初始化,跳过重复初始化', tag: 'SingBox'); + return; + } - if (_kr_subscriptions.isEmpty) { - throw Exception('订阅失败,command client 未初始化'); + // 🔧 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; } - - KRLogUtil.kr_i('✅ Command client 初始化成功', tag: 'SingBox'); - } catch (e) { - KRLogUtil.kr_e('❌ Command client 初始化失败: $e', tag: 'SingBox'); - rethrow; - } + }); } /// 在后台恢复用户保存的节点选择(不阻塞启动流程) @@ -1142,8 +1151,7 @@ class KRSingBoxImp { // 等待 command client 初始化 await Future.delayed(const Duration(milliseconds: 2000)); - final savedNode = - await KRSecureStorage().kr_readData(key: _keySelectedNode); + final savedNode = await KRSecureStorage().kr_readData(key: _keySelectedNode); if (savedNode != null && savedNode.isNotEmpty && savedNode != 'auto') { KRLogUtil.kr_i('🔄 恢复用户选择的节点: $savedNode', tag: 'SingBox'); @@ -1151,8 +1159,7 @@ class KRSingBoxImp { await _kr_selectOutboundWithRetry("select", savedNode); KRLogUtil.kr_i('✅ 节点恢复完成', tag: 'SingBox'); } catch (e) { - KRLogUtil.kr_w('⚠️ 节点恢复失败(可能 command client 未就绪): $e', - tag: 'SingBox'); + KRLogUtil.kr_w('⚠️ 节点恢复失败(可能 command client 未就绪): $e', tag: 'SingBox'); } } else { KRLogUtil.kr_i('ℹ️ 使用默认节点选择 (auto)', tag: 'SingBox'); @@ -1193,108 +1200,59 @@ class KRSingBoxImp { KRLogUtil.kr_i('📊 出站节点数量: ${outbounds.length}', tag: 'SingBox'); // 打印每个节点的详细配置 - // for (int i = 0; i < outbounds.length; i++) { - // final outbound = outbounds[i]; - // KRLogUtil.kr_i('📋 节点[$i] 配置:', tag: 'SingBox'); - // KRLogUtil.kr_i(' - type: ${outbound['type']}', tag: 'SingBox'); - // KRLogUtil.kr_i(' - tag: ${outbound['tag']}', tag: 'SingBox'); - // KRLogUtil.kr_i(' - server: ${outbound['server']}', tag: 'SingBox'); - // KRLogUtil.kr_i(' - server_port: ${outbound['server_port']}', - // tag: 'SingBox'); - // if (outbound['method'] != null) { - // KRLogUtil.kr_i(' - method: ${outbound['method']}', tag: 'SingBox'); - // } - // if (outbound['interval'] != null) { - // KRLogUtil.kr_i(' - interval: ${outbound['interval']}', tag: 'SingBox'); - // } - // if (outbound['password'] != null) { - // KRLogUtil.kr_i( - // ' - password: ${outbound['password']?.toString().substring(0, 8)}...', - // tag: 'SingBox'); - // } - // if (outbound['uuid'] != null) { - // KRLogUtil.kr_i( - // ' - uuid: ${outbound['uuid']?.toString().substring(0, 8)}...', - // tag: 'SingBox'); - // } - // KRLogUtil.kr_i(' - 完整配置: ${jsonEncode(outbound)}', tag: 'SingBox'); - // } + 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'); + 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'); + KRLogUtil.kr_i('✅ 过滤后节点数量: ${kr_outbounds.length}/${outbounds.length}', tag: 'SingBox'); - // 🔧 修复:生成完整的 SingBox 配置 - // 之前只保存 {"outbounds": [...]}, 导致 libcore 无法正确处理 - // 现在保存完整配置,包含所有必需字段 - final Map fullConfig = { - "log": {"level": "debug", "timestamp": true}, - "dns": { - "servers": [ - { - "tag": "dns-remote", - "address": "https://1.1.1.1/dns-query", - "address_resolver": "dns-direct" - }, - {"tag": "dns-direct", "address": "local", "detour": "direct"} - ], - "rules": _kr_buildDnsRules(), // ✅ 使用动态构建的 DNS 规则 - "final": "dns-remote", - "strategy": "prefer_ipv4" - }, - "inbounds": [ - { - "type": "tun", - "tag": "tun-in", - "interface_name": "utun", - "inet4_address": "172.19.0.1/30", - "auto_route": true, - "strict_route": true, - "sniff": true, - "sniff_override_destination": false - } - ], + // Use hiddify-app flow: save only base outbounds. + // libcore builds full config from HiddifyOptions. + final Map baseConfig = { "outbounds": [ - // 🔧 修复:添加 selector 组,让用户可以手动选择节点 - { - "type": "selector", - "tag": "proxy", - "outbounds": kr_outbounds.map((o) => o['tag'] as String).toList(), - "default": - kr_outbounds.isNotEmpty ? kr_outbounds[0]['tag'] : "direct", - }, + 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, - {"type": "direct", "tag": "direct"}, - {"type": "block", "tag": "block"}, - {"type": "dns", "tag": "dns-out"} ], - "route": { - "rules": _kr_buildRouteRules(), // ✅ 使用动态构建的路由规则 - "rule_set": _kr_buildRuleSets(), // ✅ 使用动态构建的规则集 - "final": "proxy", // 🔧 修复:使用 selector 组作为默认出站 - "auto_detect_interface": true - } }; final file = _file(kr_configName); final temp = _tempFile(kr_configName); - final mapStr = jsonEncode(fullConfig); + final mapStr = jsonEncode(baseConfig); - KRLogUtil.kr_i('📄 完整配置文件长度: ${mapStr.length}', tag: 'SingBox'); + 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'); + KRLogUtil.kr_i('📄 配置前800字符:\n${mapStr.substring(0, mapStr.length > 800 ? 800 : mapStr.length)}', tag: 'SingBox'); await file.writeAsString(mapStr); await temp.writeAsString(mapStr); @@ -1302,6 +1260,12 @@ class KRSingBoxImp { _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) { @@ -1318,8 +1282,7 @@ class KRSingBoxImp { // 🔧 关键修复:只有在"智能代理"模式下,才根据国家添加直连规则 // 如果是"全局代理"模式,即使选择了国家也不添加直连规则 - if (kr_connectionType.value == KRConnectionType.rule && - currentCountryCode != 'other') { + if (kr_connectionType.value == KRConnectionType.rule && currentCountryCode != 'other') { rules.add({ "rule_set": [ "geoip-$currentCountryCode", @@ -1328,8 +1291,7 @@ class KRSingBoxImp { "server": "dns-direct" }); - KRLogUtil.kr_i('✅ [智能代理模式] 添加 DNS 规则: $currentCountryCode 域名使用直连 DNS', - tag: 'SingBox'); + KRLogUtil.kr_i('✅ [智能代理模式] 添加 DNS 规则: $currentCountryCode 域名使用直连 DNS', tag: 'SingBox'); } else if (kr_connectionType.value == KRConnectionType.global) { KRLogUtil.kr_i('🌐 [全局代理模式] 跳过国家 DNS 规则,所有DNS查询走代理', tag: 'SingBox'); } @@ -1343,20 +1305,14 @@ class KRSingBoxImp { final rules = >[]; // 基础规则: DNS 查询走 dns-out - rules.add({"protocol": "dns", "outbound": "dns-out"}); - - // ✅ 自定义域名直连规则(优先级高,放在前面) - // rules.add({ - // "domain_suffix": ["ip138.com"], - // "outbound": "direct" - // }); - - // KRLogUtil.kr_i('✅ 添加自定义域名直连规则: ip138.com -> direct', tag: 'SingBox'); + rules.add({ + "protocol": "dns", + "outbound": "dns-out" + }); // 🔧 关键修复:只有在"智能代理"模式下,才根据国家添加直连规则 // 如果是"全局代理"模式,即使选择了国家也不添加直连规则 - if (kr_connectionType.value == KRConnectionType.rule && - currentCountryCode != 'other') { + if (kr_connectionType.value == KRConnectionType.rule && currentCountryCode != 'other') { rules.add({ "rule_set": [ "geoip-$currentCountryCode", @@ -1365,8 +1321,7 @@ class KRSingBoxImp { "outbound": "direct" }); - KRLogUtil.kr_i('✅ [智能代理模式] 添加路由规则: $currentCountryCode IP/域名直连', - tag: 'SingBox'); + KRLogUtil.kr_i('✅ [智能代理模式] 添加路由规则: $currentCountryCode IP/域名直连', tag: 'SingBox'); } else if (kr_connectionType.value == KRConnectionType.global) { KRLogUtil.kr_i('🌐 [全局代理模式] 跳过国家路由规则,所有流量走代理', tag: 'SingBox'); } @@ -1381,13 +1336,10 @@ class KRSingBoxImp { // 🔧 关键修复:只有在"智能代理"模式下,才加载国家规则集 // 如果是"全局代理"模式,即使选择了国家也不加载规则集 - if (kr_connectionType.value == KRConnectionType.rule && - currentCountryCode != 'other') { + 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')); + 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()) { // ✅ 使用本地文件 @@ -1395,7 +1347,7 @@ class KRSingBoxImp { "type": "local", "tag": "geoip-$currentCountryCode", "format": "binary", - "path": "./geosite/geoip-$currentCountryCode.srs" // 相对于 workingDir + "path": "./geosite/geoip-$currentCountryCode.srs" // 相对于 workingDir }); ruleSets.add({ @@ -1406,25 +1358,19 @@ class KRSingBoxImp { }); 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'); + 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'); + 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", + "url": "https://raw.githubusercontent.com/hiddify/hiddify-geo/rule-set/country/geoip-$currentCountryCode.srs", "download_detour": "direct", "update_interval": "7d" }); @@ -1433,8 +1379,7 @@ class KRSingBoxImp { "type": "remote", "tag": "geosite-$currentCountryCode", "format": "binary", - "url": - "https://raw.githubusercontent.com/hiddify/hiddify-geo/rule-set/country/geosite-$currentCountryCode.srs", + "url": "https://raw.githubusercontent.com/hiddify/hiddify-geo/rule-set/country/geosite-$currentCountryCode.srs", "download_detour": "direct", "update_interval": "7d" }); @@ -1445,6 +1390,63 @@ class KRSingBoxImp { } Future 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 _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 @@ -1453,6 +1455,14 @@ class KRSingBoxImp { 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'); @@ -1467,47 +1477,54 @@ class KRSingBoxImp { KRLogUtil.kr_i('📁 配置文件路径: $_cutPath', tag: 'SingBox'); KRLogUtil.kr_i('📝 配置名称: $kr_configName', tag: 'SingBox'); - // 🔑 Windows 平台:仅在首次启动时备份 DNS 设置(优化:避免重复备份) - if (Platform.isWindows && !_dnsBackedUp) { - KRLogUtil.kr_i('🪟 Windows 平台,首次启动备份 DNS 设置...', tag: 'SingBox'); - try { - final backupSuccess = - await KRWindowsDnsUtil.instance.kr_backupDnsSettings(); - if (backupSuccess) { - _dnsBackedUp = true; // 标记已备份 - KRLogUtil.kr_i('✅ Windows DNS 备份成功', tag: 'SingBox'); - } else { - KRLogUtil.kr_w('⚠️ Windows DNS 备份失败,将在停止时使用兜底恢复', tag: 'SingBox'); - } - } catch (e) { - KRLogUtil.kr_w('⚠️ Windows DNS 备份异常: $e,将在停止时使用兜底恢复', tag: 'SingBox'); - } - } else if (Platform.isWindows && _dnsBackedUp) { - KRLogUtil.kr_i('⏭️ Windows 平台,DNS 已备份,跳过重复备份(节点切换优化)', tag: 'SingBox'); - } + // ✅ 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'); - final oOption = SingboxConfigOption.fromJson(_getConfigOption()); + 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) { + (error) { + KRFileLogger.log('[黑屏调试] [${DateTime.now()}] ❌ changeOptions 失败: $error'); KRLogUtil.kr_e('❌ changeOptions() 失败: $error', tag: 'SingBox'); throw Exception('初始化 HiddifyOptions 失败: $error'); }, - (_) { + (_) { KRLogUtil.kr_i('✅ HiddifyOptions 初始化成功', tag: 'SingBox'); }, ); @@ -1517,9 +1534,7 @@ class KRSingBoxImp { 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'); + KRLogUtil.kr_i('📄 配置文件前500字符: ${configContent.substring(0, configContent.length > 500 ? 500 : configContent.length)}', tag: 'SingBox'); } else { KRLogUtil.kr_w('⚠️ 配置文件不存在: $_cutPath', tag: 'SingBox'); } @@ -1527,16 +1542,68 @@ class KRSingBoxImp { // 🔧 修复: 在启动前重新订阅状态流,确保能收到状态更新 _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) { + (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. 统计流需要主动订阅才能接收数据 @@ -1546,79 +1613,206 @@ class KRSingBoxImp { KRLogUtil.kr_i('✅ SingBox 核心已启动,开始初始化 command client', tag: 'SingBox'); // 🔑 在后台延迟订阅统计流和分组流,避免阻塞 UI + await KRFileLogger.log('[_kr_startInternal] 🟢 关键路径完成,返回给调用者 - ${DateTime.now()}'); Future.delayed(const Duration(milliseconds: 1000), () async { - try { - KRLogUtil.kr_i('📊 开始订阅统计数据流...', tag: 'SingBox'); - _kr_subscribeToStats(); - KRLogUtil.kr_i('✅ 统计数据流订阅成功', tag: 'SingBox'); - } catch (e) { - KRLogUtil.kr_w('⚠️ 统计数据流订阅失败(稍后重试): $e', tag: 'SingBox'); - // 如果第一次失败,再等待一段时间重试 - Future.delayed(const Duration(milliseconds: 2000), () { - try { - _kr_subscribeToStats(); - KRLogUtil.kr_i('✅ 统计数据流重试订阅成功', tag: 'SingBox'); - } catch (e2) { - KRLogUtil.kr_e('❌ 统计数据流重试订阅失败: $e2', tag: 'SingBox'); - } - }); + final backgroundStartTime = DateTime.now(); + + // 【关键】检查停止标志,如果用户在延迟期间点击了停止,就立即返回 + // 这避免了竞态条件:后台任务在 stop() 过程中仍然在执行订阅 + if (_stopRequested) { + await KRFileLogger.log('[后台任务] ⏭️ 检测到停止标志,后台任务立即返回(避免竞态条件) - $backgroundStartTime'); + return; } - // 🔧 关键修复:订阅分组数据流 try { - KRLogUtil.kr_i('📋 开始订阅分组数据流...', tag: 'SingBox'); - _kr_subscribeToGroups(); - KRLogUtil.kr_i('✅ 分组数据流订阅成功', tag: 'SingBox'); - } catch (e) { - KRLogUtil.kr_w('⚠️ 分组数据流订阅失败(稍后重试): $e', tag: 'SingBox'); - // 如果第一次失败,再等待一段时间重试 - Future.delayed(const Duration(milliseconds: 2000), () { - try { - _kr_subscribeToGroups(); - KRLogUtil.kr_i('✅ 分组数据流重试订阅成功', tag: 'SingBox'); - } catch (e2) { - KRLogUtil.kr_e('❌ 分组数据流重试订阅失败: $e2', tag: 'SingBox'); - } - }); - } + await KRFileLogger.log('[后台任务] 🔴 启动 1000ms 延迟后的后台任务 - $backgroundStartTime'); - // 🔧 关键修复:恢复用户选择的节点 - try { - final selectedNode = - await KRSecureStorage().kr_readData(key: _keySelectedNode); - if (selectedNode != null && selectedNode.isNotEmpty) { - KRLogUtil.kr_i('🎯 恢复用户选择的节点: $selectedNode', tag: 'SingBox'); - if (kDebugMode) { - print('🔵 启动后恢复节点选择: $selectedNode'); + // 订阅统计流 + 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 还没准备好!'); } - // 延迟500ms确保sing-box完全启动 - await Future.delayed(const Duration(milliseconds: 500)); + // 🔧 关键修复:如果端口检查失败,立即返回,避免 FFI 调用阻塞 UI 3+ 秒 + if (!port8964ConnectOk) { + await KRFileLogger.log('[后台任务] ⛔ 8964 端口不可用,跳过订阅操作(避免 FFI 超时阻塞 UI)- ${DateTime.now()}'); + KRLogUtil.kr_w('⚠️ sing-box command 端口不可用,跳过订阅', tag: 'SingBox'); + return; + } - // 🔧 关键修复:使用 await 等待节点切换完成 - try { - await kr_selectOutbound(selectedNode); - KRLogUtil.kr_i('✅ 节点已切换到用户选择: $selectedNode', tag: 'SingBox'); - if (kDebugMode) { - print('🔵 节点切换成功: $selectedNode'); + // 【关键】在执行订阅前再次检查停止标志 + 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; } - } catch (e) { - KRLogUtil.kr_e('❌ 节点切换失败: $e', tag: 'SingBox'); + + 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) { - print('🔵 节点切换失败: $e'); + 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('[黑屏调试] 🔵 没有保存的节点选择,使用默认'); } } - } else { - KRLogUtil.kr_i('ℹ️ 没有保存的节点选择,使用默认配置', tag: 'SingBox'); + } catch (e) { + KRLogUtil.kr_e('❌ 恢复节点选择失败: $e', tag: 'SingBox'); + await KRFileLogger.log('[后台任务] ❌ 恢复节点选择失败: $e - ${DateTime.now()}'); if (kDebugMode) { - print('🔵 没有保存的节点选择,使用默认'); + 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'); - if (kDebugMode) { - print('🔵 恢复节点选择失败: $e'); - } + KRLogUtil.kr_e('💥 后台任务异常: $e', tag: 'SingBox'); + await KRFileLogger.log('[后台任务] 💥 后台任务异常: $e - ${DateTime.now()}'); } }); } catch (e, stackTrace) { @@ -1631,131 +1825,334 @@ class KRSingBoxImp { /// 停止服务 Future 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 _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'); // 取消节点选择监控定时器 - kr_resetNodeSelectionMonitor(); + _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 秒,给 Windows DNS 清理足够时间 + // 🔧 超时时间 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 秒 + 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()}'); // 继续执行清理操作 } - // 🔑 Windows 平台:仅在有备份时恢复 DNS 设置 - if (Platform.isWindows && _dnsBackedUp) { - KRLogUtil.kr_i('🪟 Windows 平台,等待 sing-box 完全停止...', tag: 'SingBox'); + // ✅ DNS 和系统代理都由 Go 后端 (libcore.dll) 自动管理 + // 参考 Hiddify 实现:sing-box 内部有完整的 DNS 代理机制,不需要手动恢复系统 DNS + await KRFileLogger.log('[_kr_stopInternal] ℹ️ DNS 由 libcore 自动管理 - ${DateTime.now()}'); - // 🔧 P3优化: 监听状态而非固定延迟,确保 sing-box 真正停止后再恢复 DNS - try { - // 如果当前已经是停止状态,直接继续 - if (kr_status.value is SingboxStopped) { - KRLogUtil.kr_i('✅ sing-box 已经是停止状态,立即恢复 DNS', tag: 'SingBox'); - } else { - // 等待状态变为停止,最多等待3秒 - final completer = Completer(); - late final Worker worker; - - worker = ever(kr_status, (status) { - if (status is SingboxStopped) { - if (!completer.isCompleted) { - completer.complete(); - worker.dispose(); - } - } - }); - - await completer.future.timeout( - const Duration(seconds: 3), - onTimeout: () { - KRLogUtil.kr_w('⏱️ 等待停止状态超时,继续执行 DNS 恢复', tag: 'SingBox'); - worker.dispose(); - }, - ); - - KRLogUtil.kr_i('✅ sing-box 已完全停止,开始恢复 DNS...', tag: 'SingBox'); - } - } catch (e) { - KRLogUtil.kr_w('⚠️ 状态监听异常: $e,继续执行 DNS 恢复', tag: 'SingBox'); - } - - try { - // 尝试恢复 DNS - final restoreSuccess = - await KRWindowsDnsUtil.instance.kr_restoreDnsSettings(); - if (restoreSuccess) { - KRLogUtil.kr_i('✅ Windows DNS 恢复成功', tag: 'SingBox'); - } else { - KRLogUtil.kr_e('❌ Windows DNS 恢复失败', tag: 'SingBox'); - } - } catch (e) { - KRLogUtil.kr_e('❌ Windows DNS 恢复异常: $e', tag: 'SingBox'); - // 异常时也会在工具类内部执行兜底恢复 - } finally { - // 🔧 P0修复2: 使用 finally 确保标志位始终被重置,避免下次启动跳过备份 - _dnsBackedUp = false; - KRLogUtil.kr_i('🔄 重置 DNS 备份标志位', tag: 'SingBox'); - } - } else if (Platform.isWindows && !_dnsBackedUp) { - KRLogUtil.kr_i('⏭️ Windows 平台,DNS 未备份,跳过恢复', tag: 'SingBox'); - await Future.delayed(const Duration(milliseconds: 500)); - } else { - // 非 Windows 平台正常等待 - await Future.delayed(const Duration(milliseconds: 500)); + // 🍎 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) { - try { - await subscription.cancel(); - _kr_subscriptions.remove(subscription); - } catch (e) { - KRLogUtil.kr_e('取消订阅时出错: $e'); - } + _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'); - // 🔑 即使出错,也要尝试恢复 Windows DNS - if (Platform.isWindows && _dnsBackedUp) { - KRLogUtil.kr_w('⚠️ 停止异常,强制执行 DNS 恢复', tag: 'SingBox'); - try { - await KRWindowsDnsUtil.instance.kr_restoreDnsSettings(); - } catch (dnsError) { - KRLogUtil.kr_e('❌ 强制 DNS 恢复失败: $dnsError', tag: 'SingBox'); - } finally { - // 🔧 P0修复2: 异常路径也要重置标志位 - _dnsBackedUp = false; - KRLogUtil.kr_i('🔄 异常后重置 DNS 备份标志位', tag: 'SingBox'); - } - } + // ✅ 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 _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 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 _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 + 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 _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 _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 _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(); @@ -1776,27 +2173,70 @@ class KRSingBoxImp { Future kr_restart() async { KRLogUtil.kr_i('🔄 重启 SingBox...', tag: 'SingBox'); - // 🔧 修复:使用 stop + start 而不是 restart - // 这样可以触发 Windows DNS 恢复和备份逻辑 + // 🔧 P0-2: 改进异常处理 - stop 失败也要尝试 start + // 🔧 P1-1: 优化网络抖动 - 监听状态变化而非固定延迟 + // ✅ DNS/代理 由 libcore.dll 自动管理 try { - // 1. 先停止(会触发 DNS 恢复) - await kr_stop(); + // 1. 先停止 + try { + await kr_stop(); + } catch (stopError) { + KRLogUtil.kr_e('❌ 停止失败: $stopError,仍尝试启动', tag: 'SingBox'); + // 不 rethrow,继续尝试启动 + } - // 2. 等待完全停止 - await Future.delayed(const Duration(milliseconds: 500)); + // 🔧 P1-1: 2. 等待完全停止 - 监听状态而非固定延迟(减少网络中断时间) + try { + // 如果已经是停止状态,直接继续 + if (kr_status.value is SingboxStopped) { + KRLogUtil.kr_i('✅ SingBox 已是停止状态,立即启动', tag: 'SingBox'); + } else { + // 等待状态变为停止,最多等待2秒(比原来的盲等500ms更聪明) + final completer = Completer(); + late final Worker worker; - // 3. 重新启动(会触发 DNS 备份) - await kr_start(); + worker = ever(kr_status, (status) { + if (status is SingboxStopped) { + if (!completer.isCompleted) { + completer.complete(); + worker.dispose(); + KRLogUtil.kr_i('✅ 检测到 SingBox 已停止,立即启动新实例', tag: 'SingBox'); + } + } + }); - 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'); + KRLogUtil.kr_e('❌ 重启异常: $e', tag: 'SingBox'); rethrow; } } //// 设置出站模式 Future kr_updateConnectionType(KRConnectionType newType) async { + final previousType = kr_connectionType.value; // 保存原模式用于失败时回滚 + try { KRLogUtil.kr_i('🔄 开始更新连接类型...', tag: 'SingBox'); KRLogUtil.kr_i('📊 当前类型: ${kr_connectionType.value}', tag: 'SingBox'); @@ -1807,6 +2247,10 @@ class KRSingBoxImp { return; } + // ✅ 改进:直接尝试切换(不做权限检查避免黑屏) + // 关键改进:保存原模式,失败时回滚以防止自动重连机制无限循环 + KRLogUtil.kr_i('📝 尝试切换模式到: $newType(原模式: $previousType)', tag: 'SingBox'); + kr_connectionType.value = newType; final oOption = _getConfigOption(); @@ -1821,9 +2265,9 @@ class KRSingBoxImp { mode = KRCountryUtil.kr_getCurrentCountryCode(); KRLogUtil.kr_i('🎯 切换到规则代理模式: $mode', tag: 'SingBox'); break; - // case KRConnectionType.direct: - // mode = "direct"; - // break; + // case KRConnectionType.direct: + // mode = "direct"; + // break; } oOption["region"] = mode; KRLogUtil.kr_i('📝 更新 region 配置: $mode', tag: 'SingBox'); @@ -1841,15 +2285,43 @@ class KRSingBoxImp { if (kr_status.value == SingboxStarted()) { KRLogUtil.kr_i('🔄 VPN已启动,准备重启以应用新配置...', tag: 'SingBox'); - await kr_restart(); - 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'); - rethrow; + + // 最终保险:异常时确保回滚模式 + if (kr_connectionType.value != previousType) { + KRLogUtil.kr_w('🔙 异常捕获,回滚模式到: $previousType', tag: 'SingBox'); + kr_connectionType.value = previousType; + } + + // ❌ 关键修复:不要 rethrow + // 异常已经通过 Toast 由监听器提示,模式已回滚 + // 继续 rethrow 会导致对话框无法关闭,让应用看起来"卡"了 + // rethrow; } } @@ -1895,55 +2367,21 @@ class KRSingBoxImp { Timer? _nodeSelectionTimer; Future kr_selectOutbound(String tag) async { - KRLogUtil.kr_i('🎯 [v2.1] 开始选择出站节点: $tag', tag: 'SingBox'); - KRLogUtil.kr_i('📊 当前活动组数量: ${kr_activeGroups.length}', tag: 'SingBox'); - - // 如果活动组尚未就绪,等待一段时间;仍为空则仅保存选择,稍后恢复 - if (kr_activeGroups.isEmpty) { - for (int i = 0; i < 20; i++) { - await Future.delayed(const Duration(milliseconds: 100)); - if (kr_activeGroups.isNotEmpty) break; - } - if (kr_activeGroups.isEmpty) { - KRLogUtil.kr_w('⚠️ 活动组为空,跳过即时切换,仅保存选择: $tag', tag: 'SingBox'); - try { - await KRSecureStorage() - .kr_saveData(key: _keySelectedNode, value: tag); - KRLogUtil.kr_i('✅ 节点选择已保存: $tag', tag: 'SingBox'); - } catch (e) { - KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'SingBox'); - } - return; - } + // ========== 关键路径:必需的同步操作 ========== + // 🔧 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'); - bool foundNode = false; - for (var group in kr_activeGroups) { - for (var item in group.items) { - if (item.tag == tag) { - foundNode = true; - KRLogUtil.kr_i('✅ 在组 "${group.tag}" 中找到目标节点: $tag', tag: 'SingBox'); - break; - } - } - if (foundNode) break; - } - if (!foundNode) { - KRLogUtil.kr_w('⚠️ 未能在任何活动组中找到目标节点: $tag', tag: 'SingBox'); - // 打印所有可用的节点 - for (var group in kr_activeGroups) { - for (var item in group.items) { - KRLogUtil.kr_d(' 可用节点: ${item.tag}', tag: 'SingBox'); - } - } - } + KRLogUtil.kr_i('🎯 开始选择出站节点: $tag', tag: 'SingBox'); // 🔧 关键修复:使用 await 确保保存完成 try { await KRSecureStorage().kr_saveData(key: _keySelectedNode, value: tag); - KRLogUtil.kr_i('✅ 节点选择已保存: $tag', tag: 'SingBox'); + if (kDebugMode) { + KRLogUtil.kr_d('✅ 节点选择已保存: $tag', tag: 'SingBox'); + } } catch (e) { KRLogUtil.kr_e('❌ 保存节点选择失败: $e', tag: 'SingBox'); } @@ -1951,31 +2389,168 @@ class KRSingBoxImp { // 🔧 关键修复:使用 await 确保 command client 初始化完成 try { await _kr_ensureCommandClientInitialized(); - KRLogUtil.kr_i('✅ Command client 已就绪,执行节点切换', tag: 'SingBox'); + 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'; - KRLogUtil.kr_i('⏳ 调用 selectOutbound("$selectorGroupTag", "$tag")...', - tag: 'SingBox'); - await _kr_selectOutboundWithRetry(selectorGroupTag, tag, - maxAttempts: 3, initialDelay: 50); - KRLogUtil.kr_i('✅ 节点切换API调用完成: $tag', tag: 'SingBox'); + final selectorGroupTag = kr_activeGroups.any((g) => g.tag == 'select') ? 'select' : 'proxy'; - // 🔧 新增:验证节点切换是否生效 - await Future.delayed(const Duration(milliseconds: 300)); // 等待活动组更新 - await _kr_verifyNodeSelection(tag); + // ⚡ 关键优化:核心 API 调用 + await _kr_selectOutboundWithRetry(selectorGroupTag, tag, maxAttempts: 3, initialDelay: 50); + + if (kDebugMode) { + KRLogUtil.kr_d('✅ 节点切换API调用完成: $tag', tag: 'SingBox'); + } + + // ========== 关键路径结束!以下操作都在后台进行 ========== + // ⚡ 后台任务:验证 + 刷新 + 监控(不影响返回) + unawaited(_kr_backgroundNodeVerificationAndRefresh(tag)); - KRLogUtil.kr_i('✅ 节点切换验证完成: $tag', tag: 'SingBox'); } catch (e) { KRLogUtil.kr_e('❌ 节点选择失败: $e', tag: 'SingBox'); rethrow; // 抛出异常,让调用者知道失败了 } + } - // 🔄 如果用户选择了具体节点(不是 auto),启动定期检查和重新选择 - // 这是为了防止 urltest 自动覆盖用户的手动选择 - kr_startNodeSelectionMonitor(tag); + /// ⚡ 后台验证和刷新(不影响 UI 响应) + /// 包含:验证节点选择 + TUN 模式刷新 + 启动监控定时器 + Future _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 _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 _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; + } } /// 配合文件地址 @@ -2019,6 +2594,133 @@ class KRSingBoxImp { // File tempFile(String fileName) => file("$fileName.tmp"); + /// ✅ 临时切换模式以完全断开浏览器连接(推荐方案) + /// 原理: + /// TUN 模式:浏览器 → 操作系统 → sing-box → 节点(浏览器无法关闭连接)❌ + /// 系统代理:浏览器 → 代理软件 → 节点(代理可强制关闭连接)✅ + /// 流程: + /// 1. TUN → 系统代理(这时代理会主动关闭所有旧连接) + /// 2. 发送 HTTP 请求(刷新代理缓存) + /// 3. 系统代理 → TUN(恢复原状态) + /// 优点:100% 彻底断开,无残留连接,UX 无感知(<100ms) + Future _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 _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 _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 kr_urlTest(String groupTag) async { KRLogUtil.kr_i('🧪 开始 URL 测试: $groupTag', tag: 'SingBox'); KRLogUtil.kr_i('📊 当前活动组数量: ${kr_activeGroups.length}', tag: 'SingBox'); @@ -2026,14 +2728,10 @@ class KRSingBoxImp { // 打印所有活动组信息 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'); + 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(' └─ 节点[$j]: tag=${item.tag}, type=${item.type}, delay=${item.urlTestDelay}', tag: 'SingBox'); } } @@ -2049,14 +2747,10 @@ class KRSingBoxImp { 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'); + 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(' └─ 节点[$j]: tag=${item.tag}, type=${item.type}, delay=${item.urlTestDelay}', tag: 'SingBox'); } } } catch (e) { @@ -2064,4 +2758,475 @@ class KRSingBoxImp { KRLogUtil.kr_e('📚 错误详情: ${e.toString()}', tag: 'SingBox'); } } + + /// 🪟 Windows: 检查当前进程是否有管理员权限(TUN 模式需要) + /// 返回 true 表示有管理员权限,false 表示无权限 + /// ✅ 公开方法:检查 Windows 管理员权限 + /// 返回 true: 有管理员权限,可以使用 TUN 模式 + /// 返回 false: 无管理员权限,只能使用规则/系统代理模式 + Future kr_checkWindowsAdminPrivilege() async { + return await _kr_checkWindowsAdminPrivilege(); + } + + /// 私有方法:内部实现(带缓存) + /// ✅ 改进:使用 FFI 直接调用 IsUserAnAdmin() Windows API + /// ⭐ 优化: + /// - 不启动新进程,无黑屏风险 + /// - 直接内存操作,速度快(1-5ms vs 600ms) + /// - 零资源开销 + /// - Windows 官方 API,完全可靠 + Future _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 _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 _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 _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 _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 直接操作,无需快照对象 diff --git a/lib/app/themes/kr_theme_service.dart b/lib/app/themes/kr_theme_service.dart index 1cb0ad0..4b88c4f 100755 --- a/lib/app/themes/kr_theme_service.dart +++ b/lib/app/themes/kr_theme_service.dart @@ -15,11 +15,31 @@ class KRThemeService extends GetxService { final String _key = 'themeOption'; // 存储主题选项的键 late ThemeMode _currentThemeOption = ThemeMode.light; // 当前主题选项 + // 🔧 P0修复: 添加初始化状态标记,防止未初始化就使用 + bool _isInitialized = false; + /// 初始化时从存储中加载主题设置 Future init() async { - _currentThemeOption = await kr_loadThemeOptionFromStorage(); + try { + _currentThemeOption = await kr_loadThemeOptionFromStorage(); + _isInitialized = true; + } catch (e) { + // 初始化失败时使用默认主题 + _currentThemeOption = ThemeMode.light; + _isInitialized = true; + print('⚠️ 主题初始化失败,使用默认主题: $e'); + } } + /// 🔧 P0修复: 重置主题服务状态(用于热重载/应用恢复) + Future reset() async { + _isInitialized = false; + await init(); + } + + /// 🔧 P0修复: 检查是否已初始化 + bool get isInitialized => _isInitialized; + /// 获取当前主题模式 ThemeMode get kr_Theme { switch (_currentThemeOption) { @@ -82,7 +102,7 @@ class KRThemeService extends GetxService { ), elevatedButtonTheme: ElevatedButtonThemeData( style: - ElevatedButton.styleFrom(backgroundColor: Colors.green), // 自定义的按钮颜色 + ElevatedButton.styleFrom(backgroundColor: Colors.green), // 自定义的按钮颜色 ), switchTheme: SwitchThemeData( thumbColor: WidgetStateProperty.all(Colors.white), // 开关按钮颜色 @@ -100,10 +120,10 @@ class KRThemeService extends GetxService { // class KRColors { // // 1. 底部导航栏背景色(稍深的蓝黑色) // static const Color kr_bottomNavBackground = Color(0xFF161920); - + // // 2. 列表整体背景色(深蓝黑色) // static const Color kr_listBackground = Color(0xFF1A1D24); - + // // 3. 列表项背景色(略浅于列表背景的蓝黑色) // static const Color kr_listItemBackground = Color(0xFF1E2128); // } @@ -142,18 +162,6 @@ class KRThemeService extends GetxService { return Colors.transparent; // 打开时不显示边框 }), ), - snackBarTheme: SnackBarThemeData( - backgroundColor: Colors.black, // 黑色背景 - contentTextStyle: TextStyle( - color: Colors.white, // 白色文字 - fontSize: 14, - ), - actionTextColor: Colors.white, // 操作按钮文字颜色 - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), // 圆角 - ), - behavior: SnackBarBehavior.floating, // 浮动样式 - ), // 其他自定义颜色 ); } diff --git a/lib/app/utils/kr_debounce_throttle_util.dart b/lib/app/utils/kr_debounce_throttle_util.dart new file mode 100644 index 0000000..bcd1e12 --- /dev/null +++ b/lib/app/utils/kr_debounce_throttle_util.dart @@ -0,0 +1,281 @@ +/// 🔧 防抖和限流工具类 - 用于处理快速点击导致的重复请求 +/// +/// 使用场景: +/// 1. 防抖(Debounce):用户快速点击按钮,等用户停止点击后再执行一次 +/// - 模式切换、搜索、自动保存 +/// 2. 限流(Throttle):在给定时间内最多执行一次 +/// - 节点切换、数据刷新、VPN 启动/停止 + +import 'dart:async'; +import 'package:get/get.dart'; + +class KRDebounceThrottleUtil { + // 防抖计时器缓存(防止多个防抖冲突) + static final Map _debounceTimers = {}; + + // 限流时间戳缓存 + static final Map _throttleTimestamps = {}; + + /// 🔧 防抖函数:等待 delay 时间无新请求后才执行 + /// + /// 适用于:模式切换、搜索、自动保存等 + /// + /// 例子: + /// ```dart + /// KRDebounceThrottleUtil.debounce( + /// key: 'mode_switch', + /// delay: Duration(milliseconds: 300), + /// action: () { + /// controller.kr_updateConnectionType(newType); + /// }, + /// ); + /// ``` + static void debounce({ + required String key, + required Duration delay, + required VoidCallback action, + }) { + // 如果有旧的待执行任务,取消它 + _debounceTimers[key]?.cancel(); + + // 设置新的延迟任务 + _debounceTimers[key] = Timer(delay, () { + action(); + _debounceTimers.remove(key); + }); + } + + /// 🔧 异步防抖函数(支持 Future) + /// + /// 适用于需要等待异步操作的场景 + /// + /// 例子: + /// ```dart + /// await KRDebounceThrottleUtil.debounceAsync( + /// key: 'node_switch', + /// delay: Duration(milliseconds: 500), + /// action: () async { + /// await controller.kr_performNodeSwitch(tag); + /// }, + /// ); + /// ``` + static Future debounceAsync({ + required String key, + required Duration delay, + required Future Function() action, + }) async { + _debounceTimers[key]?.cancel(); + + return Future.delayed(delay).then((_) async { + await action(); + _debounceTimers.remove(key); + }); + } + + /// 🔧 限流函数:在指定时间内最多执行一次 + /// + /// 返回值:true 表示执行成功,false 表示被限流(仍在冷却中) + /// + /// 适用于:节点切换、数据刷新、VPN 启动/停止等频繁操作 + /// + /// 例子: + /// ```dart + /// final canExecute = KRDebounceThrottleUtil.throttle( + /// key: 'refresh', + /// duration: Duration(seconds: 2), + /// ); + /// + /// if (canExecute) { + /// await controller.kr_refreshAll(); + /// } else { + /// showToast('操作过于频繁,请稍后再试'); + /// } + /// ``` + static bool throttle({ + required String key, + required Duration duration, + }) { + final now = DateTime.now(); + final lastExecuteTime = _throttleTimestamps[key]; + + // 如果这是第一次执行或已经过了冷却时间 + if (lastExecuteTime == null || + now.difference(lastExecuteTime).inMilliseconds >= duration.inMilliseconds) { + _throttleTimestamps[key] = now; + return true; // 允许执行 + } + + return false; // 仍在冷却期,拒绝执行 + } + + /// 🔧 异步限流函数 + /// + /// 返回值:true 表示执行成功,false 表示被限流 + /// + /// 例子: + /// ```dart + /// final success = await KRDebounceThrottleUtil.throttleAsync( + /// key: 'node_switch', + /// duration: Duration(milliseconds: 2000), + /// action: () async { + /// await controller.kr_performNodeSwitch(tag); + /// }, + /// ); + /// ``` + static Future throttleAsync({ + required String key, + required Duration duration, + required Future Function() action, + }) async { + if (throttle(key: key, duration: duration)) { + try { + await action(); + return true; + } catch (e) { + // 如果执行失败,重置时间戳以允许重试 + _throttleTimestamps.remove(key); + rethrow; + } + } + return false; + } + + /// 🔧 获取某个 key 的剩余冷却时间(毫秒) + /// + /// 返回值: + /// - 0 或负数:可以执行 + /// - 正数:还需等待的毫秒数 + static int getRemainingThrottleTime({ + required String key, + required Duration duration, + }) { + final lastExecuteTime = _throttleTimestamps[key]; + if (lastExecuteTime == null) return 0; + + final remaining = duration.inMilliseconds - + DateTime.now().difference(lastExecuteTime).inMilliseconds; + return remaining > 0 ? remaining : 0; + } + + /// 🔧 清除指定 key 的所有计时器(调试用) + static void clear({String? key}) { + if (key != null) { + _debounceTimers[key]?.cancel(); + _debounceTimers.remove(key); + _throttleTimestamps.remove(key); + } else { + // 清除所有 + for (var timer in _debounceTimers.values) { + timer.cancel(); + } + _debounceTimers.clear(); + _throttleTimestamps.clear(); + } + } + + /// 🔧 获取防抖和限流的统计信息(调试用) + static Map getStats() { + return { + 'activeDebounces': _debounceTimers.keys.toList(), + 'activeThrottles': _throttleTimestamps.keys.toList(), + 'totalActiveTimers': _debounceTimers.length + _throttleTimestamps.length, + }; + } +} + +/// 🔧 防抖辅助类 - 用于在 Controller 中创建防抖版本的方法 +/// +/// 例子: +/// ```dart +/// class MyController extends GetxController { +/// late final _debouncer = KRDebouncedMethod( +/// key: 'mode_switch', +/// delay: Duration(milliseconds: 300), +/// ); +/// +/// void kr_updateConnectionType(KRConnectionType type) { +/// _debouncer.call(() async { +/// // 实际的业务逻辑 +/// }); +/// } +/// } +/// ``` +class KRDebouncedMethod { + final String key; + final Duration delay; + + KRDebouncedMethod({ + required this.key, + this.delay = const Duration(milliseconds: 300), + }); + + void call(VoidCallback action) { + KRDebounceThrottleUtil.debounce( + key: key, + delay: delay, + action: action, + ); + } + + Future callAsync(Future Function() action) async { + return KRDebounceThrottleUtil.debounceAsync( + key: key, + delay: delay, + action: action, + ); + } + + void cancel() { + KRDebounceThrottleUtil.clear(key: key); + } +} + +/// 🔧 限流辅助类 - 用于在 Controller 中创建限流版本的方法 +/// +/// 例子: +/// ```dart +/// class MyController extends GetxController { +/// late final _throttler = KRThrottledMethod( +/// key: 'refresh', +/// duration: Duration(seconds: 2), +/// ); +/// +/// void kr_refreshAll() { +/// if (_throttler.canExecute()) { +/// // 执行刷新逻辑 +/// } +/// } +/// } +/// ``` +class KRThrottledMethod { + final String key; + final Duration duration; + + KRThrottledMethod({ + required this.key, + required this.duration, + }); + + bool canExecute() { + return KRDebounceThrottleUtil.throttle(key: key, duration: duration); + } + + Future executeAsync(Future Function() action) async { + return KRDebounceThrottleUtil.throttleAsync( + key: key, + duration: duration, + action: action, + ); + } + + int getRemainingTime() { + return KRDebounceThrottleUtil.getRemainingThrottleTime( + key: key, + duration: duration, + ); + } + + void reset() { + KRDebounceThrottleUtil.clear(key: key); + } +} diff --git a/lib/app/utils/kr_file_logger.dart b/lib/app/utils/kr_file_logger.dart new file mode 100644 index 0000000..0a7958b --- /dev/null +++ b/lib/app/utils/kr_file_logger.dart @@ -0,0 +1,82 @@ +import 'dart:io'; +import 'package:path/path.dart' as p; + +/// 文件日志工具类 - 用于诊断 UI 卡死问题 +/// +/// 将关键操作的时间戳写入 日志.log 文件,方便排查问题 +/// 使用全局开关控制是否启用日志写入 +class KRFileLogger { + static const String _logFileName = '日志.log'; + + /// 🔧 全局日志开关 - 修改为 true 可启用文件日志写入 + static const bool _enableFileLogging = true; // ⚠️ 调试模式:开启日志分析UI阻塞 // ← 诊断完成后改回 false + + static File? _logFile; + + /// 初始化日志文件(应用启动时调用) + static Future initialize() async { + if (!_enableFileLogging) return; + + try { + // 获取应用根目录(Windows 下通常是 exe 所在目录) + final appDir = Directory.current; + final logPath = p.join(appDir.path, _logFileName); + _logFile = File(logPath); + + // 如果日志文件已存在且大于 5MB,清空它 + if (await _logFile!.exists()) { + final stat = await _logFile!.stat(); + if (stat.size > 5 * 1024 * 1024) { // 5MB + await _logFile!.writeAsString(''); + await _writeRaw('=== 日志文件已清空(超过 5MB)===\n'); + } + } + + await _writeRaw('=== 日志系统初始化 - ${DateTime.now().toIso8601String()} ===\n'); + } catch (e) { + // 初始化失败,静默处理,不影响应用 + } + } + + /// 写入日志(带时间戳) + static Future log(String message) async { + if (!_enableFileLogging || _logFile == null) return; + + try { + final timestamp = DateTime.now().toIso8601String(); + final logLine = '[$timestamp] $message\n'; + await _logFile!.writeAsString(logLine, mode: FileMode.append); + } catch (e) { + // 日志写入失败,静默处理,不要破坏主流程 + } + } + + /// 直接写入原始内容(用于特殊格式) + static Future _writeRaw(String content) async { + if (!_enableFileLogging || _logFile == null) return; + + try { + await _logFile!.writeAsString(content, mode: FileMode.append); + } catch (e) { + // 日志写入失败,静默处理 + } + } + + /// 写入分隔线(用于区分不同的操作) + static Future separator() async { + if (!_enableFileLogging) return; + await _writeRaw('\n---\n'); + } + + /// 清空日志文件 + static Future clear() async { + if (!_enableFileLogging || _logFile == null) return; + + try { + await _logFile!.writeAsString(''); + await _writeRaw('=== 日志已清空 - ${DateTime.now().toIso8601String()} ===\n'); + } catch (e) { + // 清空失败,静默处理 + } + } +} diff --git a/lib/app/utils/kr_window_manager.dart b/lib/app/utils/kr_window_manager.dart index 00fa4d2..c795a2d 100755 --- a/lib/app/utils/kr_window_manager.dart +++ b/lib/app/utils/kr_window_manager.dart @@ -26,11 +26,11 @@ class KRWindowManager with WindowListener, TrayListener { const WindowOptions windowOptions = WindowOptions( size: Size(800, 668), - minimumSize: Size(400, 334), + minimumSize: Size(800, 668), center: true, backgroundColor: Colors.white, skipTaskbar: false, - title: 'Hi快VPN', + title: 'Kaer VPN', titleBarStyle: TitleBarStyle.normal, windowButtonVisibility: true, ); @@ -47,16 +47,17 @@ class KRWindowManager with WindowListener, TrayListener { await windowManager.setTitleBarStyle(TitleBarStyle.normal); await windowManager.setTitle('HiFastVPN'); await windowManager.setSize(const Size(800, 668)); - await windowManager.setMinimumSize(const Size(400, 334)); + await windowManager.setMinimumSize(const Size(800, 668)); await windowManager.center(); await windowManager.show(); // 阻止窗口关闭 await windowManager.setPreventClose(true); } else { - await windowManager.setTitle('HiFastVPN'); + await windowManager.setTitle('Kaer VPN'); await windowManager.setSize(const Size(800, 668)); - await windowManager.setMinimumSize(const Size(400, 334)); + await windowManager.setMinimumSize(const Size(800, 668)); await windowManager.center(); + await windowManager.show(); // macOS 也需要显式显示窗口 } // 初始化托盘 @@ -94,7 +95,7 @@ class KRWindowManager with WindowListener, TrayListener { /// 初始化平台通道 void _initPlatformChannel() { if (Platform.isMacOS) { - const platform = MethodChannel('hifast_vpn/terminate'); + const platform = MethodChannel('kaer_vpn/terminate'); platform.setMethodCallHandler((call) async { if (call.method == 'onTerminate') { KRLogUtil.kr_i('收到应用终止通知'); @@ -144,13 +145,71 @@ class KRWindowManager with WindowListener, TrayListener { final String kr_port = KRSingBoxImp.instance.kr_port.toString(); final String proxyText = 'export https_proxy=http://127.0.0.1:$kr_port http_proxy=http://127.0.0.1:$kr_port all_proxy=socks5://127.0.0.1:$kr_port'; - + await Clipboard.setData(ClipboardData(text: proxyText)); } /// 退出应用 + /// ✅ 改进:先恢复窗口(如果最小化),再显示对话框 Future _exitApp() async { KRLogUtil.kr_i('_exitApp: 退出应用'); + + // ✅ 关键修复:先恢复窗口(从最小化状态) + // 这样可以确保对话框可见 + try { + await windowManager.show(); + await windowManager.focus(); + await windowManager.setAlwaysOnTop(true); + KRLogUtil.kr_i('✅ 窗口已恢复,准备显示对话框', tag: 'WindowManager'); + } catch (e) { + KRLogUtil.kr_w('⚠️ 恢复窗口失败(可能已显示): $e', tag: 'WindowManager'); + } + + // 🔧 修复:检查 VPN 是否在运行,如果运行则弹窗提醒用户 + if (KRSingBoxImp.instance.kr_status.value is! SingboxStopped) { + KRLogUtil.kr_w('⚠️ VPN 正在运行,询问用户是否关闭', tag: 'WindowManager'); + + // 显示确认对话框 + final shouldExit = await Get.dialog( + AlertDialog( + title: Text('关闭 VPN'), + content: Text("VPN 代理正在运行。\n\n是否现在关闭 VPN 并退出应用?\n\n(应用将等待 VPN 优雅关闭,预计 3-5 秒)"), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: Text('取消'), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: Text('关闭并退出', style: const TextStyle(color: Colors.red)), + ), + ], + ), + barrierDismissible: false, + ) ?? false; + + // ✅ 关键修复:对话框关闭后,恢复窗口的 AlwaysOnTop 状态 + try { + await windowManager.setAlwaysOnTop(false); + } catch (e) { + KRLogUtil.kr_w('⚠️ 恢复 AlwaysOnTop 失败: $e', tag: 'WindowManager'); + } + + if (!shouldExit) { + KRLogUtil.kr_i('_exitApp: 用户取消退出'); + return; + } + + KRLogUtil.kr_i('_exitApp: 用户确认关闭 VPN 并退出'); + } else { + // ✅ VPN 未运行,也要恢复 AlwaysOnTop 状态 + try { + await windowManager.setAlwaysOnTop(false); + } catch (e) { + KRLogUtil.kr_w('⚠️ 恢复 AlwaysOnTop 失败: $e', tag: 'WindowManager'); + } + } + await _handleTerminate(); await windowManager.destroy(); } @@ -159,9 +218,9 @@ class KRWindowManager with WindowListener, TrayListener { Future _showWindow() async { KRLogUtil.kr_i('_showWindow: 开始显示窗口'); try { + await windowManager.setSkipTaskbar(false); await windowManager.show(); await windowManager.focus(); - await windowManager.setSkipTaskbar(false); await windowManager.setAlwaysOnTop(true); await Future.delayed(const Duration(milliseconds: 100)); await windowManager.setAlwaysOnTop(false); @@ -180,6 +239,7 @@ class KRWindowManager with WindowListener, TrayListener { @override void onWindowClose() async { if (Platform.isWindows) { + await windowManager.setSkipTaskbar(true); await windowManager.hide(); } else if (Platform.isMacOS) { await windowManager.hide(); @@ -216,9 +276,29 @@ class KRWindowManager with WindowListener, TrayListener { /// 处理应用终止 Future _handleTerminate() async { KRLogUtil.kr_i('_handleTerminate: 处理应用终止'); - if (KRSingBoxImp.instance.kr_status == SingboxStatus.started()) { - await KRSingBoxImp.instance.kr_stop(); + + // 🔧 修复 BUG:正确检查 VPN 状态而不是直接比较 Rx 对象 + // 之前的代码:if (KRSingBoxImp.instance.kr_status == SingboxStatus.started()) + // 问题:kr_status 是 Rx 对象,不能直接与 SingboxStatus.started() 比较 + // 结果:该条件总是 false,导致 kr_stop() 从不被调用,VPN 不会关闭 + if (KRSingBoxImp.instance.kr_status.value is SingboxStarted) { + KRLogUtil.kr_i('🛑 VPN 正在运行,开始关闭...', tag: 'WindowManager'); + try { + await KRSingBoxImp.instance.kr_stop(); + KRLogUtil.kr_i('✅ VPN 已关闭', tag: 'WindowManager'); + } catch (e) { + KRLogUtil.kr_e('❌ VPN 关闭出错: $e', tag: 'WindowManager'); + } + } else { + KRLogUtil.kr_i('✅ VPN 未运行,无需关闭', tag: 'WindowManager'); + } + + // 销毁托盘 + try { + await trayManager.destroy(); + KRLogUtil.kr_i('✅ 托盘已销毁', tag: 'WindowManager'); + } catch (e) { + KRLogUtil.kr_w('⚠️ 销毁托盘出错: $e', tag: 'WindowManager'); } - await trayManager.destroy(); } } diff --git a/lib/app/utils/kr_windows_dns_util.dart b/lib/app/utils/kr_windows_dns_util.dart index d09d37a..64fcfbd 100644 --- a/lib/app/utils/kr_windows_dns_util.dart +++ b/lib/app/utils/kr_windows_dns_util.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:kaer_with_panels/app/utils/kr_log_util.dart'; +import 'package:kaer_with_panels/app/utils/kr_windows_process_util.dart'; /// Windows DNS 管理工具类 /// @@ -38,28 +39,48 @@ class KRWindowsDnsUtil { } try { - KRLogUtil.kr_i('📦 开始备份 Windows DNS 设置...', tag: 'WindowsDNS'); + // 🔒 添加5秒超时保护 + return await Future.value(() async { + KRLogUtil.kr_i('📦 开始备份 Windows DNS 设置...', tag: 'WindowsDNS'); - // 1. 获取主网络接口 - final interfaceName = await _kr_getPrimaryNetworkInterface(); - if (interfaceName == null) { - KRLogUtil.kr_e('❌ 无法获取主网络接口', tag: 'WindowsDNS'); - return false; - } - _primaryInterfaceName = interfaceName; - KRLogUtil.kr_i('🔍 主网络接口: $_primaryInterfaceName', tag: 'WindowsDNS'); + // 1. 获取主网络接口 + final interfaceName = await _kr_getPrimaryNetworkInterface(); + if (interfaceName == null) { + KRLogUtil.kr_e('❌ 无法获取主网络接口', tag: 'WindowsDNS'); + return false; + } + _primaryInterfaceName = interfaceName; + KRLogUtil.kr_i('🔍 主网络接口: $_primaryInterfaceName', tag: 'WindowsDNS'); - // 2. 获取当前 DNS 服务器 - final dnsServers = await _kr_getCurrentDnsServers(interfaceName); - if (dnsServers.isEmpty) { - KRLogUtil.kr_w('⚠️ 当前 DNS 为空,可能是自动获取', tag: 'WindowsDNS'); - _originalDnsServers = []; // 空列表表示 DHCP 自动获取 - } else { - _originalDnsServers = dnsServers; - KRLogUtil.kr_i('✅ 已备份 DNS: ${dnsServers.join(", ")}', tag: 'WindowsDNS'); - } + // 2. 获取当前 DNS 服务器 + final dnsServers = await _kr_getCurrentDnsServers(interfaceName); - return true; + // 🔧 P0修复1: 过滤掉 127.0.0.1 (sing-box 的本地 DNS) + // 原因:如果备份了 127.0.0.1,关闭 VPN 后恢复为 127.0.0.1,但 sing-box 已停止,导致 DNS 无法解析 + final validDnsServers = dnsServers.where((dns) => !dns.startsWith('127.')).toList(); + + if (validDnsServers.isEmpty) { + KRLogUtil.kr_w('⚠️ 当前 DNS 为空或全是本地地址,设为 DHCP 自动获取', tag: 'WindowsDNS'); + if (dnsServers.isNotEmpty) { + KRLogUtil.kr_i(' (已过滤的本地DNS: ${dnsServers.join(", ")})', tag: 'WindowsDNS'); + } + _originalDnsServers = []; // 空列表表示 DHCP 自动获取 + } else { + _originalDnsServers = validDnsServers; + KRLogUtil.kr_i('✅ 已备份有效 DNS: ${validDnsServers.join(", ")}', tag: 'WindowsDNS'); + if (dnsServers.length != validDnsServers.length) { + KRLogUtil.kr_i(' (已过滤掉 ${dnsServers.length - validDnsServers.length} 个本地地址)', tag: 'WindowsDNS'); + } + } + + return true; + }()).timeout( + const Duration(seconds: 5), + onTimeout: () { + KRLogUtil.kr_w('⏱️ DNS 备份操作超时(5秒),跳过备份', tag: 'WindowsDNS'); + return false; + }, + ); } catch (e) { KRLogUtil.kr_e('❌ 备份 DNS 设置失败: $e', tag: 'WindowsDNS'); return false; @@ -80,22 +101,33 @@ class KRWindowsDnsUtil { try { KRLogUtil.kr_i('🔄 开始恢复 Windows DNS 设置...', tag: 'WindowsDNS'); - // 1. 检查是否有备份 - if (_primaryInterfaceName == null) { - KRLogUtil.kr_w('⚠️ 没有备份的网络接口,尝试自动检测', tag: 'WindowsDNS'); - _primaryInterfaceName = await _kr_getPrimaryNetworkInterface(); - if (_primaryInterfaceName == null) { - KRLogUtil.kr_e('❌ 无法检测网络接口,执行兜底恢复', tag: 'WindowsDNS'); - return await _kr_fallbackRestoreDns(); - } + // 🔧 P1修复: 恢复时重新检测主接口,防止网络切换导致恢复错误接口 + final currentInterface = await _kr_getPrimaryNetworkInterface(); + if (currentInterface == null) { + KRLogUtil.kr_e('❌ 无法检测当前网络接口,执行兜底恢复', tag: 'WindowsDNS'); + return await _kr_fallbackRestoreDns(); } - // 2. 恢复原始 DNS + // 检查接口是否变化 + if (_primaryInterfaceName != null && _primaryInterfaceName != currentInterface) { + KRLogUtil.kr_w('⚠️ 网络接口已变化: $_primaryInterfaceName → $currentInterface', tag: 'WindowsDNS'); + KRLogUtil.kr_w(' 执行兜底恢复以确保当前接口DNS正常', tag: 'WindowsDNS'); + _primaryInterfaceName = currentInterface; // 更新为当前接口 + return await _kr_fallbackRestoreDns(); + } + + // 使用当前检测到的接口 + _primaryInterfaceName = currentInterface; + KRLogUtil.kr_i('🔍 当前网络接口: $_primaryInterfaceName', tag: 'WindowsDNS'); + + // 1. 检查是否有备份的DNS if (_originalDnsServers == null) { KRLogUtil.kr_w('⚠️ 没有备份的 DNS,执行兜底恢复', tag: 'WindowsDNS'); return await _kr_fallbackRestoreDns(); } + // 2. 恢复原始 DNS + if (_originalDnsServers!.isEmpty) { // 原本是 DHCP 自动获取 KRLogUtil.kr_i('🔄 恢复为 DHCP 自动获取 DNS', tag: 'WindowsDNS'); @@ -130,6 +162,15 @@ class KRWindowsDnsUtil { return await _kr_fallbackRestoreDns(); } + // 🔧 P2优化: 测试 DNS 解析是否真正可用 + KRLogUtil.kr_i('🧪 测试 DNS 解析功能...', tag: 'WindowsDNS'); + final canResolve = await _kr_testDnsResolution(); + if (!canResolve) { + KRLogUtil.kr_w('⚠️ DNS 解析测试失败,执行兜底恢复', tag: 'WindowsDNS'); + return await _kr_fallbackRestoreDns(); + } + KRLogUtil.kr_i('✅ DNS 解析测试通过', tag: 'WindowsDNS'); + return true; } catch (e) { KRLogUtil.kr_e('❌ 恢复 DNS 设置失败: $e', tag: 'WindowsDNS'); @@ -187,7 +228,7 @@ class KRWindowsDnsUtil { Future _kr_getPrimaryNetworkInterface() async { try { // 使用 netsh 获取接口列表 - final result = await Process.run('netsh', ['interface', 'show', 'interface']); + final result = await KRWindowsProcessUtil.runHidden('netsh', ['interface', 'show', 'interface']); if (result.exitCode != 0) { KRLogUtil.kr_e('❌ 获取网络接口失败: ${result.stderr}', tag: 'WindowsDNS'); @@ -271,7 +312,7 @@ class KRWindowsDnsUtil { /// 返回:DNS 服务器列表 Future> _kr_getCurrentDnsServers(String interfaceName) async { try { - final result = await Process.run('netsh', [ + final result = await KRWindowsProcessUtil.runHidden('netsh', [ 'interface', 'ipv4', 'show', @@ -294,10 +335,9 @@ class KRWindowsDnsUtil { final ipMatch = RegExp(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b').firstMatch(line); if (ipMatch != null) { final ip = ipMatch.group(0)!; - // 排除本地回环地址 - if (!ip.startsWith('127.')) { - dnsServers.add(ip); - } + // 🔧 关键修复:不过滤127.0.0.1,以便正确检测DNS是否还在使用sing-box的本地DNS + // 这样在恢复DNS时,第126行的验证才能正确检测到127.0.0.1并触发兜底恢复 + dnsServers.add(ip); } } @@ -325,7 +365,7 @@ class KRWindowsDnsUtil { // 1. 设置主 DNS KRLogUtil.kr_i('🔧 设置主 DNS: ${dnsServers[0]}', tag: 'WindowsDNS'); - var result = await Process.run('netsh', [ + var result = await KRWindowsProcessUtil.runHidden('netsh', [ 'interface', 'ipv4', 'set', @@ -345,7 +385,7 @@ class KRWindowsDnsUtil { if (dnsServers.length > 1) { for (int i = 1; i < dnsServers.length; i++) { KRLogUtil.kr_i('🔧 设置备用 DNS ${i}: ${dnsServers[i]}', tag: 'WindowsDNS'); - result = await Process.run('netsh', [ + result = await KRWindowsProcessUtil.runHidden('netsh', [ 'interface', 'ipv4', 'add', @@ -384,7 +424,7 @@ class KRWindowsDnsUtil { try { KRLogUtil.kr_i('🔧 设置 DNS 为自动获取 (DHCP)', tag: 'WindowsDNS'); - final result = await Process.run('netsh', [ + final result = await KRWindowsProcessUtil.runHidden('netsh', [ 'interface', 'ipv4', 'set', @@ -416,7 +456,7 @@ class KRWindowsDnsUtil { try { KRLogUtil.kr_i('🔄 刷新 DNS 缓存...', tag: 'WindowsDNS'); - final result = await Process.run('ipconfig', ['/flushdns']); + final result = await KRWindowsProcessUtil.runHidden('ipconfig', ['/flushdns']); if (result.exitCode == 0) { KRLogUtil.kr_i('✅ DNS 缓存已刷新', tag: 'WindowsDNS'); @@ -428,6 +468,51 @@ class KRWindowsDnsUtil { } } + /// 🔧 P2优化: 测试 DNS 解析是否真正可用 + /// + /// 通过 nslookup 测试常见域名解析 + /// 返回:true 表示 DNS 可用,false 表示 DNS 不可用 + Future _kr_testDnsResolution() async { + try { + // 测试多个常见域名,提高成功率 + final testDomains = ['www.baidu.com', 'www.qq.com', 'dns.alidns.com']; + + for (var domain in testDomains) { + try { + // 使用 nslookup 测试 DNS 解析,设置 2 秒超时 + final result = await KRWindowsProcessUtil.runHidden( + 'nslookup', + [domain], + ).timeout( + const Duration(seconds: 2), + onTimeout: () { + return ProcessResult(0, 1, '', 'Timeout'); + }, + ); + + if (result.exitCode == 0) { + final output = result.stdout.toString(); + // 检查输出是否包含 IP 地址(简单验证) + if (output.contains('Address:') || output.contains('地址:')) { + KRLogUtil.kr_i('✅ DNS 解析测试通过: $domain', tag: 'WindowsDNS'); + return true; + } + } + } catch (e) { + // 单个域名失败,继续测试下一个 + continue; + } + } + + // 所有域名都解析失败 + KRLogUtil.kr_w('⚠️ 所有测试域名解析均失败', tag: 'WindowsDNS'); + return false; + } catch (e) { + KRLogUtil.kr_e('❌ DNS 解析测试异常: $e', tag: 'WindowsDNS'); + return false; + } + } + /// 清除备份数据 /// /// 在应用退出或不需要时调用 diff --git a/lib/app/utils/kr_windows_process_util.dart b/lib/app/utils/kr_windows_process_util.dart new file mode 100644 index 0000000..47dbc5f --- /dev/null +++ b/lib/app/utils/kr_windows_process_util.dart @@ -0,0 +1,602 @@ +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'kr_file_logger.dart'; + +class KRWindowsProcessUtil { + /// 🔍 调试标志:设为 true 可以追踪所有命令执行(用于排查黑窗问题) + static const bool _debugCommandExecution = true; // ← 开启调试 + + static Future runHidden(String executable, List arguments) async { + final timestamp = DateTime.now().toString(); + // 🔧 使用非阻塞日志(不 await),避免影响执行时序 + KRFileLogger.log('[黑屏调试] [KRWindowsProcessUtil] [$timestamp] 🔧 runHidden 调用: $executable ${arguments.join(" ")}'); + + if (!Platform.isWindows) { + KRFileLogger.log('[黑屏调试] [KRWindowsProcessUtil] [$timestamp] ⚠️ 非 Windows,使用 Process.run(可能黑窗)'); + return Process.run(executable, arguments); + } + + KRFileLogger.log('[黑屏调试] [KRWindowsProcessUtil] [$timestamp] ✅ Windows,使用 CreateProcessW(无黑窗)'); + final result = await _runHiddenWindows(executable, arguments); + KRFileLogger.log('[黑屏调试] [KRWindowsProcessUtil] [$timestamp] 📤 执行完成: exitCode=${result.exitCode}'); + return result; + } + + static Future startHidden(String executable, List arguments) async { + if (!Platform.isWindows) { + final process = await Process.start(executable, arguments); + return process.pid; + } + return _startHiddenWindows(executable, arguments); + } + + static String _buildCommandLine(String executable, List arguments) { + final parts = [_quoteArgument(executable)]; + for (final arg in arguments) { + parts.add(_quoteArgument(arg)); + } + return parts.join(' '); + } + + static bool _shouldSearchPath(String executable) { + if (executable.isEmpty) { + return true; + } + return !(executable.contains('\\') || executable.contains('/') || executable.contains(':')); + } + + static String _quoteArgument(String value) { + if (value.isEmpty) { + return '""'; + } + final needsQuotes = value.contains(' ') || value.contains('\t') || value.contains('"'); + if (!needsQuotes) { + return value; + } + final buffer = StringBuffer('"'); + var backslashes = 0; + for (var i = 0; i < value.length; i++) { + final char = value[i]; + if (char == '\\') { + backslashes++; + continue; + } + if (char == '"') { + buffer.write('\\' * (backslashes * 2 + 1)); + buffer.write('"'); + backslashes = 0; + continue; + } + if (backslashes > 0) { + buffer.write('\\' * backslashes); + backslashes = 0; + } + buffer.write(char); + } + if (backslashes > 0) { + buffer.write('\\' * (backslashes * 2)); + } + buffer.write('"'); + return buffer.toString(); + } + + static Future _runHiddenWindows(String executable, List arguments) async { + final stdoutPipe = _createPipe(); + final stderrPipe = _createPipe(); + + final startupInfo = calloc(); + final processInfo = calloc(); + final commandLine = _buildCommandLine(executable, arguments).toNativeUtf16(); + final applicationName = _shouldSearchPath(executable) ? nullptr : executable.toNativeUtf16(); + final stdInput = _getStdInputHandle(); + + startupInfo.ref + ..cb = sizeOf() + ..dwFlags = STARTF_USESTDHANDLES + ..hStdInput = stdInput + ..hStdOutput = stdoutPipe.write + ..hStdError = stderrPipe.write; + + final created = _CreateProcessW( + applicationName, + commandLine, + nullptr, + nullptr, + TRUE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + startupInfo, + processInfo, + ); + + calloc.free(commandLine); + if (applicationName != nullptr) { + calloc.free(applicationName); + } + + if (created == 0) { + _closeHandle(stdoutPipe.read); + _closeHandle(stdoutPipe.write); + _closeHandle(stderrPipe.read); + _closeHandle(stderrPipe.write); + calloc.free(startupInfo); + calloc.free(processInfo); + throw Exception('CreateProcessW failed: ${_GetLastError()}'); + } + + _closeHandle(stdoutPipe.write); + _closeHandle(stderrPipe.write); + + final output = await _collectOutput(processInfo.ref.hProcess, stdoutPipe.read, stderrPipe.read); + final exitCode = _getExitCode(processInfo.ref.hProcess); + + _closeHandle(stdoutPipe.read); + _closeHandle(stderrPipe.read); + _closeHandle(processInfo.ref.hThread); + _closeHandle(processInfo.ref.hProcess); + + final pid = processInfo.ref.dwProcessId; + calloc.free(startupInfo); + calloc.free(processInfo); + + return ProcessResult(pid, exitCode, output.stdout, output.stderr); + } + + static Future _startHiddenWindows(String executable, List arguments) async { + final startupInfo = calloc(); + final processInfo = calloc(); + final commandLine = _buildCommandLine(executable, arguments).toNativeUtf16(); + final applicationName = _shouldSearchPath(executable) ? nullptr : executable.toNativeUtf16(); + + startupInfo.ref.cb = sizeOf(); + + final created = _CreateProcessW( + applicationName, + commandLine, + nullptr, + nullptr, + FALSE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + startupInfo, + processInfo, + ); + + calloc.free(commandLine); + if (applicationName != nullptr) { + calloc.free(applicationName); + } + + if (created == 0) { + calloc.free(startupInfo); + calloc.free(processInfo); + throw Exception('CreateProcessW failed: ${_GetLastError()}'); + } + + final pid = processInfo.ref.dwProcessId; + + _closeHandle(processInfo.ref.hThread); + _closeHandle(processInfo.ref.hProcess); + calloc.free(startupInfo); + calloc.free(processInfo); + + return pid; + } + + static _Pipe _createPipe() { + final readHandle = calloc>(); + final writeHandle = calloc>(); + final securityAttributes = calloc(); + securityAttributes.ref + ..nLength = sizeOf() + ..bInheritHandle = TRUE + ..lpSecurityDescriptor = nullptr; + + final created = _CreatePipe(readHandle, writeHandle, securityAttributes, 0); + calloc.free(securityAttributes); + if (created == 0) { + calloc.free(readHandle); + calloc.free(writeHandle); + throw Exception('CreatePipe failed: ${_GetLastError()}'); + } + + final readValue = readHandle.value; + final writeValue = writeHandle.value; + calloc.free(readHandle); + calloc.free(writeHandle); + + final infoResult = _SetHandleInformation(readValue, HANDLE_FLAG_INHERIT, 0); + if (infoResult == 0) { + _closeHandle(readValue); + _closeHandle(writeValue); + throw Exception('SetHandleInformation failed: ${_GetLastError()}'); + } + + return _Pipe(readValue, writeValue); + } + + static Pointer _getStdInputHandle() { + final handle = _GetStdHandle(STD_INPUT_HANDLE); + if (handle == INVALID_HANDLE_VALUE || handle == 0) { + return nullptr; + } + return Pointer.fromAddress(handle); + } + + static Future<_ProcessOutput> _collectOutput( + Pointer process, + Pointer stdoutHandle, + Pointer stderrHandle, + ) async { + final stdoutBuilder = BytesBuilder(); + final stderrBuilder = BytesBuilder(); + + while (true) { + final stdoutRead = _drainPipe(stdoutHandle, stdoutBuilder); + final stderrRead = _drainPipe(stderrHandle, stderrBuilder); + final waitResult = _WaitForSingleObject(process, 0); + if (waitResult == WAIT_OBJECT_0) { + break; + } + if (waitResult == WAIT_FAILED) { + throw Exception('WaitForSingleObject failed: ${_GetLastError()}'); + } + if (!stdoutRead && !stderrRead) { + await Future.delayed(const Duration(milliseconds: 10)); + } else { + await Future.delayed(Duration.zero); + } + } + + while (_drainPipe(stdoutHandle, stdoutBuilder) || _drainPipe(stderrHandle, stderrBuilder)) { + await Future.delayed(Duration.zero); + } + + return _ProcessOutput( + _decodeOutput(stdoutBuilder), + _decodeOutput(stderrBuilder), + ); + } + + static bool _drainPipe(Pointer handle, BytesBuilder builder) { + final buffer = calloc(4096); + final bytesRead = calloc(); + final available = calloc(); + var didRead = false; + + while (true) { + final peekOk = _PeekNamedPipe(handle, nullptr, 0, nullptr, available, nullptr); + if (peekOk == 0 || available.value == 0) { + break; + } + final toRead = available.value < 4096 ? available.value : 4096; + final ok = _ReadFile(handle, buffer.cast(), toRead, bytesRead, nullptr); + final read = ok == 0 ? 0 : bytesRead.value; + if (read == 0) { + break; + } + builder.add(buffer.asTypedList(read)); + didRead = true; + } + + calloc.free(buffer); + calloc.free(bytesRead); + calloc.free(available); + + return didRead; + } + + static String _decodeOutput(BytesBuilder builder) { + if (builder.length == 0) { + return ''; + } + final bytes = builder.toBytes(); + try { + return systemEncoding.decode(bytes); + } catch (_) { + return utf8.decode(bytes, allowMalformed: true); + } + } + + static int _getExitCode(Pointer process) { + final exitCode = calloc(); + final ok = _GetExitCodeProcess(process, exitCode); + final code = ok == 0 ? -1 : exitCode.value; + calloc.free(exitCode); + return code; + } + + static void _closeHandle(Pointer handle) { + if (handle == nullptr) { + return; + } + _CloseHandle(handle); + } + + // 🔧 WinINet API helpers for proxy settings + /// 查询当前系统代理设置 + static String? queryWindowsProxyServer() { + if (!Platform.isWindows) return null; + + try { + final bufferSize = calloc(); + bufferSize.value = sizeOf(); + + final proxyInfo = calloc(); + + final result = _InternetQueryOptionW(nullptr, INTERNET_OPTION_PROXY, proxyInfo.cast(), bufferSize); + if (result == 0) { + calloc.free(bufferSize); + calloc.free(proxyInfo); + return null; + } + + final proxyServer = proxyInfo.ref.lpszProxy.toDartString(); + calloc.free(bufferSize); + calloc.free(proxyInfo); + + return proxyServer.isEmpty ? null : proxyServer; + } catch (e) { + return null; + } + } + + /// 设置系统代理 + static bool setWindowsProxyServer(String? server) { + if (!Platform.isWindows) return false; + + try { + final proxyInfo = calloc(); + + if (server != null && server.isNotEmpty) { + // 设置代理模式 + proxyInfo.ref.dwAccessType = INTERNET_OPEN_TYPE_PROXY; + proxyInfo.ref.lpszProxy = server.toNativeUtf16(); + proxyInfo.ref.lpszProxyBypass = ''.toNativeUtf16(); + } else { + // 禁用代理 + proxyInfo.ref.dwAccessType = INTERNET_OPEN_TYPE_DIRECT; + proxyInfo.ref.lpszProxy = nullptr; + proxyInfo.ref.lpszProxyBypass = nullptr; + } + + final result = _InternetSetOptionW( + nullptr, + INTERNET_OPTION_PROXY, + proxyInfo.cast(), + sizeOf(), + ); + + if (server != null && server.isNotEmpty) { + calloc.free(proxyInfo.ref.lpszProxy); + calloc.free(proxyInfo.ref.lpszProxyBypass); + } + calloc.free(proxyInfo); + + return result != 0; + } catch (e) { + return false; + } + } + + /// 禁用系统代理 + static bool disableWindowsProxy() { + return setWindowsProxyServer(null); + } +} + +class _Pipe { + final Pointer read; + final Pointer write; + + _Pipe(this.read, this.write); +} + +class _ProcessOutput { + final String stdout; + final String stderr; + + _ProcessOutput(this.stdout, this.stderr); +} + +const int TRUE = 1; +const int FALSE = 0; + +const int STARTF_USESTDHANDLES = 0x00000100; +const int CREATE_NO_WINDOW = 0x08000000; +const int HANDLE_FLAG_INHERIT = 0x00000001; +const int WAIT_OBJECT_0 = 0x00000000; +const int WAIT_FAILED = 0xFFFFFFFF; +const int STD_INPUT_HANDLE = -10; +const int INVALID_HANDLE_VALUE = -1; + +final class SECURITY_ATTRIBUTES extends Struct { + @Uint32() + external int nLength; + + external Pointer lpSecurityDescriptor; + + @Int32() + external int bInheritHandle; +} + +final class STARTUPINFO extends Struct { + @Uint32() + external int cb; + + external Pointer lpReserved; + + external Pointer lpDesktop; + + external Pointer lpTitle; + + @Uint32() + external int dwX; + + @Uint32() + external int dwY; + + @Uint32() + external int dwXSize; + + @Uint32() + external int dwYSize; + + @Uint32() + external int dwXCountChars; + + @Uint32() + external int dwYCountChars; + + @Uint32() + external int dwFillAttribute; + + @Uint32() + external int dwFlags; + + @Uint16() + external int wShowWindow; + + @Uint16() + external int cbReserved2; + + external Pointer lpReserved2; + + external Pointer hStdInput; + + external Pointer hStdOutput; + + external Pointer hStdError; +} + +final class PROCESS_INFORMATION extends Struct { + external Pointer hProcess; + + external Pointer hThread; + + @Uint32() + external int dwProcessId; + + @Uint32() + external int dwThreadId; +} + +final DynamicLibrary _kernel32 = DynamicLibrary.open('kernel32.dll'); + +final _CreatePipe = _kernel32.lookupFunction< + Int32 Function(Pointer>, Pointer>, Pointer, Uint32), + int Function(Pointer>, Pointer>, Pointer, int)>( + 'CreatePipe', +); + +final _SetHandleInformation = _kernel32.lookupFunction< + Int32 Function(Pointer, Uint32, Uint32), + int Function(Pointer, int, int)>( + 'SetHandleInformation', +); + +final _CreateProcessW = _kernel32.lookupFunction< + Int32 Function( + Pointer, + Pointer, + Pointer, + Pointer, + Int32, + Uint32, + Pointer, + Pointer, + Pointer, + Pointer, + ), + int Function( + Pointer, + Pointer, + Pointer, + Pointer, + int, + int, + Pointer, + Pointer, + Pointer, + Pointer, + )>( + 'CreateProcessW', +); + +final _PeekNamedPipe = _kernel32.lookupFunction< + Int32 Function(Pointer, Pointer, Uint32, Pointer, Pointer, Pointer), + int Function(Pointer, Pointer, int, Pointer, Pointer, Pointer)>( + 'PeekNamedPipe', +); + +final _ReadFile = _kernel32.lookupFunction< + Int32 Function(Pointer, Pointer, Uint32, Pointer, Pointer), + int Function(Pointer, Pointer, int, Pointer, Pointer)>( + 'ReadFile', +); + +final _CloseHandle = _kernel32.lookupFunction< + Int32 Function(Pointer), + int Function(Pointer)>( + 'CloseHandle', +); + +final _WaitForSingleObject = _kernel32.lookupFunction< + Uint32 Function(Pointer, Uint32), + int Function(Pointer, int)>( + 'WaitForSingleObject', +); + +final _GetStdHandle = _kernel32.lookupFunction< + IntPtr Function(Int32), + int Function(int)>( + 'GetStdHandle', +); + +final _GetExitCodeProcess = _kernel32.lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, Pointer)>( + 'GetExitCodeProcess', +); + +final _GetLastError = _kernel32.lookupFunction< + Uint32 Function(), + int Function()>( + 'GetLastError', +); + +// 🔧 WinINet API for proxy settings - 用于替代 reg 命令,消除黑屏 +final DynamicLibrary _wininet = DynamicLibrary.open('wininet.dll'); + +const int INTERNET_OPTION_PROXY = 38; +const int INTERNET_OPEN_TYPE_PROXY = 3; +const int INTERNET_OPEN_TYPE_DIRECT = 1; + +final class INTERNET_PROXY_INFO extends Struct { + @Int32() + external int dwAccessType; + + external Pointer lpszProxy; + + external Pointer lpszProxyBypass; +} + +/// WinINet InternetSetOption API - 用于设置系统代理 +final _InternetSetOptionW = _wininet.lookupFunction< + Int32 Function(Pointer, Uint32, Pointer, Uint32), + int Function(Pointer, int, Pointer, int)>( + 'InternetSetOptionW', +); + +/// WinINet InternetQueryOption API - 用于查询系统代理 +final _InternetQueryOptionW = _wininet.lookupFunction< + Int32 Function(Pointer, Uint32, Pointer, Pointer), + int Function(Pointer, int, Pointer, Pointer)>( + 'InternetQueryOptionW', +); diff --git a/lib/main.dart b/lib/main.dart index c4631bc..b8a5bf7 100755 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; // import 'package:flutter_easyloading/flutter_easyloading.dart'; // 已替换为自定义组件 import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter/services.dart'; @@ -17,12 +19,16 @@ import 'package:kaer_with_panels/app/routes/app_pages.dart'; import 'package:kaer_with_panels/app/widgets/kr_local_image.dart'; import 'package:kaer_with_panels/app/utils/kr_window_manager.dart'; - +import 'app/services/singbox_imp/kr_sing_box_imp.dart'; +import 'app/common/app_run_data.dart'; import 'app/utils/kr_secure_storage.dart'; import 'app/common/app_config.dart'; import 'app/services/kr_site_config_service.dart'; import 'app/services/global_overlay_service.dart'; import 'app/utils/kr_log_util.dart'; +import 'app/utils/kr_secure_storage.dart'; +import 'app/utils/kr_init_log_collector.dart'; +import 'app/utils/kr_file_logger.dart'; import 'package:kaer_with_panels/app/routes/transitions/slide_transparent_transition.dart'; import 'package:kaer_with_panels/app/routes/transitions/transition_config.dart'; @@ -31,173 +37,357 @@ import 'package:kaer_with_panels/app/routes/transitions/transition_config.dart'; final GlobalKey navigatorKey = GlobalKey(); void main() async { - WidgetsFlutterBinding.ensureInitialized(); + // 🔧 全局异常捕获:捕获所有未处理的异常 + runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); - // 防止首帧渲染过早(重点) - WidgetsBinding.instance.deferFirstFrame(); + // 🔧 初始化日志收集器(必须最先初始化) + final logCollector = KRInitLogCollector(); + // 🔧 P6修复: 必须await完成初始化,否则日志会丢失 + await logCollector.initialize(); + logCollector.log('🚀 应用启动', tag: 'MAIN'); - // ✅ 等待 Flutter 确保窗口尺寸准备完毕(关键修复) - await Future.delayed(const Duration(milliseconds: 100)); + // 🔧 诊断信息: 记录平台和设备信息 + logCollector.log('平台: ${Platform.operatingSystem}', tag: 'DEVICE'); + logCollector.log('系统版本: ${Platform.operatingSystemVersion}', tag: 'DEVICE'); + logCollector.log('Dart版本: ${Platform.version}', tag: 'DEVICE'); - if (Platform.isAndroid || Platform.isIOS) { - await SystemChrome.setPreferredOrientations( - [DeviceOrientation.portraitUp, DeviceOrientation.portraitDown], + // 🔧 Flutter框架异常捕获 + FlutterError.onError = (FlutterErrorDetails details) { + logCollector.logError( + 'Flutter框架异常: ${details.exception}', + tag: 'FLUTTER_ERROR', + error: details.exception, + stackTrace: details.stack, + ); + FlutterError.presentError(details); + }; + + // 🔧 异步异常捕获(PlatformDispatcher) + PlatformDispatcher.instance.onError = (error, stack) { + logCollector.logError( + '平台异步异常: $error', + tag: 'PLATFORM_ERROR', + error: error, + stackTrace: stack, + ); + return true; + }; + + await _initializeApp(logCollector); + }, (error, stack) { + // 🔧 Zone捕获的未处理异常 + final logCollector = KRInitLogCollector(); + logCollector.logError( + 'Zone未处理异常: $error', + tag: 'ZONE_ERROR', + error: error, + stackTrace: stack, ); + }); +} + +Future _initializeApp(KRInitLogCollector logCollector) async { + try { + logCollector.logPhaseStart('初始化核心服务'); + + // 🔧 初始化文件日志系统(用于诊断 UI 卡死问题) + logCollector.log('初始化文件日志系统', tag: 'FILE_LOGGER'); + await KRFileLogger.initialize(); + await KRFileLogger.log('📝 文件日志系统已启动'); + logCollector.logSuccess('文件日志系统初始化完成', tag: 'FILE_LOGGER'); + + // 为所有 HttpClient 请求统一注入代理策略 + logCollector.log('配置 HTTP 代理策略', tag: 'HTTP'); + HttpOverrides.global = KRProxyHttpOverrides(); + + // 初始化 Hive + logCollector.log('初始化 Hive 数据库', tag: 'HIVE'); + await KRSecureStorage().kr_initHive(); + logCollector.logSuccess('Hive 初始化完成', tag: 'HIVE'); + + // 初始化主题 + logCollector.log('初始化主题服务', tag: 'THEME'); + await KRThemeService().init(); + logCollector.logSuccess('主题初始化完成', tag: 'THEME'); + + // 初始化翻译 + logCollector.log('加载多语言翻译', tag: 'I18N'); + final translations = GetxTranslations(); + await translations.loadAllTranslations(); + logCollector.logSuccess('翻译加载完成', tag: 'I18N'); + + // 获取最后保存的语言 + final initialLocale = await KRLanguageUtils.getLastSavedLocale(); + logCollector.log('使用语言: ${initialLocale.toString()}', tag: 'I18N'); + + logCollector.logPhaseEnd('初始化核心服务', success: true); + + // 启动域名预检测(异步,不阻塞应用启动) + logCollector.log('启动域名预检测', tag: 'DOMAIN'); + KRDomain.kr_preCheckDomains(); + + logCollector.log('✅ 核心服务初始化完成,启动应用', tag: 'MAIN'); + // 🔧 P9修复: 不要立即finalize,让日志文件保持打开以收集后续信息 + // finalize会在应用关闭或_AppLifecycleWrapper的dispose时调用 + + // 初始化全局 Overlay 服务 + Get.put(GlobalOverlayService()); + + // 🔧 关键修复:必须先 runApp(),让 Flutter 引擎启动 + // 窗口管理器初始化移到 runApp 之后,通过 WidgetsBinding.addPostFrameCallback 延迟执行 + // 这样可以确保窗口显示时 Flutter UI 已经渲染完成 + runApp(_myApp(translations, initialLocale)); + + // 🔧 在 Flutter 引擎启动后再初始化窗口管理器 + if (Platform.isMacOS || Platform.isWindows) { + // 使用 addPostFrameCallback 确保在首帧渲染后再显示窗口 + WidgetsBinding.instance.addPostFrameCallback((_) async { + logCollector.logPhaseStart('初始化窗口管理器'); + await KRWindowManager().kr_initWindowManager(); + logCollector.logPhaseEnd('初始化窗口管理器', success: true); + }); + } + } catch (error, stackTrace) { + logCollector.logError( + '应用初始化失败', + tag: 'INIT_FATAL', + error: error, + stackTrace: stackTrace, + ); + await logCollector.finalize(); + rethrow; } - - // 初始化日志系统 - KRLogUtil.kr_init(); - - // Stripe.publishableKey = 'pk_live_51SbuYxAawOMH8rEEkz6f4mnxAUjGC72eQ6qdm5tT6whC4hULkxxdbiPsB4gSCIMnNIGCsIgeASTXBakUcbOuUwQO00jSWjuufx'; - // Stripe.merchantIdentifier = 'merchant.com.taw.hifastvpn'; - // if (Platform.isIOS) { - // await Stripe.instance.applySettings(); - // } - // 初始化 Hive - await KRSecureStorage().kr_initHive(); - - - // 初始化主题 - await KRThemeService().init(); - - // 初始化翻译 - final translations = GetxTranslations(); - await translations.loadAllTranslations(); - - // 获取最后保存的语言 - final initialLocale = await KRLanguageUtils.getLastSavedLocale(); - - - - - // 初始化窗口管理器 - if (Platform.isMacOS || Platform.isWindows) { - - await KRWindowManager().kr_initWindowManager(); - } - - // 启动域名预检测(异步,不阻塞应用启动) - // 立即启动域名检测,不延迟 - KRDomain.kr_preCheckDomains(); - - // 初始化 FMTC - // try { - // if (Platform.isMacOS) { - // final baseDir = await getApplicationSupportDirectory(); - // await FMTCObjectBoxBackend().initialise(rootDirectory: baseDir.path); - // } else { - // await FMTCObjectBoxBackend().initialise(); - // } - // // 创建地图存储 - // await FMTCStore(KRFMTC.kr_storeName).manage.create(); - // // 初始化地图缓存 - // await KRFMTC.kr_initMapCache(); - // } catch (error, stackTrace) { - - // } - - // 初始化全局 Overlay 服务 - Get.put(GlobalOverlayService()); - - runApp(_myApp(translations, initialLocale)); - WidgetsBinding.instance.allowFirstFrame(); } Widget _myApp(GetxTranslations translations, Locale initialLocale) { - return GetMaterialApp( - navigatorKey: navigatorKey, // 使用全局导航键 - title: "Hi快VPN", - initialRoute: Routes.KR_SPLASH, - getPages: AppPages.routes, - builder: (context, child) { - if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { - /// 屏幕适配 - ScreenUtil.init(context, - designSize: const Size(868, 668), minTextAdapt: true); - } else { - /// 屏幕适配 - ScreenUtil.init(context, - designSize: const Size(375, 667), minTextAdapt: true); - } - - // child = FlutterEasyLoading(child: child); // 已替换为自定义组件 - - // 添加生命周期监听 - Widget wrappedChild = Listener( - onPointerDown: (_) async { - // 确保地图缓存已初始化 - // try { - // final store = FMTCStore(KRFMTC.kr_storeName); - // if (!await store.manage.ready) { - // await store.manage.create(); - // await KRFMTC.kr_initMapCache(); - // } - // } catch (e) { - // print('地图缓存初始化失败: $e'); - // } - }, - child: child, - ); - - // 如果是 Mac 平台,添加顶部安全区域 - if (Platform.isMacOS) { - wrappedChild = MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: MediaQuery.of(context).padding.copyWith( - top: 10.w, // Mac 平台顶部安全区域 - ), - ), - child: wrappedChild, + return _AppLifecycleWrapper( + child: GetMaterialApp( + navigatorKey: navigatorKey, // 使用全局导航键 + title: "Hi快VPN", + initialRoute: Routes.KR_SPLASH, + getPages: AppPages.routes, + builder: (context, child) { + // 🔧 P2修复: 将ScreenUtil初始化移到StatefulWidget中,避免重复初始化 + return _ScreenUtilInitializer( + child: child ?? Container(), ); - } - - // 使用 Stack 布局来设置背景 - return Stack( - fit: StackFit.expand, - children: [ - // 背景层:使用您的自定义组件 - const KrLocalImage( - imageName: 'global-bg', - imageType: ImageType.jpg, - fit: BoxFit.cover, // 确保背景覆盖整个屏幕 - ), - // 内容层 - wrappedChild, - Overlay( - initialEntries: [ - OverlayEntry(builder: (_) => const SizedBox.shrink()), // 初始化空 Overlay - ], - ), - ], - ); - }, - theme: KRThemeService().kr_lightTheme(), - darkTheme: KRThemeService().kr_darkTheme(), - themeMode: KRThemeService().kr_Theme, - translations: translations, - locale: initialLocale, - fallbackLocale: const Locale('zh', 'CN'), - debugShowCheckedModeBanner: false, - transitionDuration: TransitionConfig.defaultDuration, // 设置动画持续时间 - customTransition: TransitionConfig.createDefaultTransition(), - routingCallback: (routing) { - if (routing == null) return; - if(Routes.KR_PURCHASE_MEMBERSHIP.contains(routing.current)) return; - // 需要显示订阅按钮的路由列表 - const showButtonRoutes = [ - Routes.MR_LOGIN, - Routes.HI_MENU, - Routes.KR_HOME, - Routes.HI_USER_INFO, - Routes.KR_ORDER_STATUS, - ]; - print('routing.current${routing.current}'); - GlobalOverlayService.instance.updateSubscriptionButtonColor(null); - if (showButtonRoutes.contains(routing.current)) { - GlobalOverlayService.instance.safeShowSubscriptionButton(); - } else { - GlobalOverlayService.instance.hideSubscriptionButton(); - } - }, + }, + theme: KRThemeService().kr_lightTheme(), + darkTheme: KRThemeService().kr_darkTheme(), + themeMode: KRThemeService().kr_Theme, + translations: translations, + locale: initialLocale, + fallbackLocale: const Locale('zh', 'CN'), + debugShowCheckedModeBanner: false, + transitionDuration: TransitionConfig.defaultDuration, // 设置动画持续时间 + customTransition: TransitionConfig.createDefaultTransition(), + routingCallback: (routing) { + if (routing == null) return; + if(Routes.KR_PURCHASE_MEMBERSHIP.contains(routing.current)) return; + // 需要显示订阅按钮的路由列表 + const showButtonRoutes = [ + Routes.MR_LOGIN, + Routes.HI_MENU, + Routes.KR_HOME, + Routes.HI_USER_INFO, + Routes.KR_ORDER_STATUS, + ]; + print('routing.current${routing.current}'); + GlobalOverlayService.instance.updateSubscriptionButtonColor(null); + if (showButtonRoutes.contains(routing.current)) { + GlobalOverlayService.instance.safeShowSubscriptionButton(); + } else { + GlobalOverlayService.instance.hideSubscriptionButton(); + } + }, + ) ); } + +/// 🔧 P2修复: ScreenUtil初始化包装器,避免重复初始化 +/// 🔧 P0修复: 使用静态变量确保全局只初始化一次,防止Widget重建导致的状态丢失 +class _ScreenUtilInitializer extends StatefulWidget { + final Widget child; + + const _ScreenUtilInitializer({required this.child}); + + @override + State<_ScreenUtilInitializer> createState() => _ScreenUtilInitializerState(); +} + +class _ScreenUtilInitializerState extends State<_ScreenUtilInitializer> { + // 🔧 P0修复: 使用静态变量,确保即使Widget重建也不会重复初始化 + static bool _isGlobalInitialized = false; + // 🔧 P0修复: 记录初始化时的屏幕尺寸,用于检测屏幕变化 + static Size? _lastScreenSize; + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final currentSize = mediaQuery.size; + + // 🔧 P0修复: 只在以下情况初始化: + // 1. 从未初始化过 + // 2. 屏幕尺寸发生显著变化(如旋转屏幕) + final needsInit = !_isGlobalInitialized || + (_lastScreenSize != null && + ((_lastScreenSize!.width - currentSize.width).abs() > 50 || + (_lastScreenSize!.height - currentSize.height).abs() > 50)); + + if (needsInit) { + // 🔧 P9修复: 记录屏幕信息(延迟记录,确保能写入文件) + if (kDebugMode || AppConfig.enableInitLogCollection) { + // 延迟记录,确保在UI线程空闲时执行 + WidgetsBinding.instance.addPostFrameCallback((_) { + final logCollector = KRInitLogCollector(); + logCollector.log('━━━ 屏幕信息诊断 ━━━', tag: 'SCREEN'); + logCollector.log('屏幕尺寸: ${mediaQuery.size.width} x ${mediaQuery.size.height}', tag: 'SCREEN'); + logCollector.log('设备像素比: ${mediaQuery.devicePixelRatio}', tag: 'SCREEN'); + logCollector.log('文本缩放: ${mediaQuery.textScaler.scale(1.0)}', tag: 'SCREEN'); + logCollector.log('安全区域: top=${mediaQuery.padding.top}, bottom=${mediaQuery.padding.bottom}', tag: 'SCREEN'); + }); + } + + if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { + // 🔧 修复:设计尺寸改为实际窗口尺寸 800x668,确保字体和UI元素正确缩放 + ScreenUtil.init(context, + designSize: const Size(800, 668), minTextAdapt: true); + if (kDebugMode || AppConfig.enableInitLogCollection) { + KRInitLogCollector().log('ScreenUtil初始化: 桌面模式 800x668', tag: 'SCREEN'); + } + } else { + ScreenUtil.init(context, + designSize: const Size(375, 667), minTextAdapt: true); + if (kDebugMode || AppConfig.enableInitLogCollection) { + KRInitLogCollector().log('ScreenUtil初始化: 移动模式 375x667', tag: 'SCREEN'); + } + } + _isGlobalInitialized = true; + _lastScreenSize = currentSize; + } + + // 如果是 Mac 平台,添加顶部安全区域 + Widget wrappedChild = widget.child; + if (Platform.isMacOS) { + wrappedChild = MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: MediaQuery.of(context).padding.copyWith( + top: 10.w, // Mac 平台顶部安全区域 + ), + ), + child: wrappedChild, + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + // 背景层:使用您的自定义组件 + const KrLocalImage( + imageName: 'global-bg', + imageType: ImageType.jpg, + fit: BoxFit.cover, // 确保背景覆盖整个屏幕 + ), + // 内容层 + wrappedChild, + Overlay( + initialEntries: [ + OverlayEntry(builder: (_) => const SizedBox.shrink()), // 初始化空 Overlay + ], + ), + ], + ); + } +} + +/// 🔧 P3修复: 应用生命周期监听包装器 +class _AppLifecycleWrapper extends StatefulWidget { + final Widget child; + + const _AppLifecycleWrapper({required this.child}); + + @override + State<_AppLifecycleWrapper> createState() => _AppLifecycleWrapperState(); +} + +class _AppLifecycleWrapperState extends State<_AppLifecycleWrapper> + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + // 🔧 P9修复: 应用关闭时finalize日志文件 + KRInitLogCollector().finalize(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (kDebugMode) { + print('🔄 应用生命周期变化: $state'); + } + + // 🔧 P3修复: 当应用从后台恢复时,重置关键服务 + if (state == AppLifecycleState.resumed) { + if (kDebugMode) { + print('♻️ 应用恢复,开始重置关键服务...'); + } + _resetCriticalServices(); + } + } + + /// 🔧 P3修复: 重置关键服务,防止状态污染 + Future _resetCriticalServices() async { + try { + // 🔧 P1修复: 重置主题服务 + await KRThemeService().reset(); + if (kDebugMode) { + print('✅ 主题服务已重置'); + } + + // 🔧 P1修复: 重置应用运行时数据 + await KRAppRunData.getInstance().kr_resetRuntimeState(); + if (kDebugMode) { + print('✅ 应用运行时数据已重置'); + } + + // 🔧 P1修复: 清理域名检测状态 + KRDomain.kr_resetDomainState(); + if (kDebugMode) { + print('✅ 域名状态已重置'); + } + + // 刷新UI + if (mounted) { + setState(() {}); + } + } catch (e) { + if (kDebugMode) { + print('⚠️ 服务重置失败: $e'); + } + } + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +/// 全局 HttpOverrides,确保所有 dart:io 网络请求遵循 sing-box 代理策略 +class KRProxyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + final client = super.createHttpClient(context); + client.findProxy = (uri) => KRSingBoxImp.instance.kr_buildProxyRule(); + return client; + } +} \ No newline at end of file diff --git a/lib/singbox/service/ffi_singbox_service.dart b/lib/singbox/service/ffi_singbox_service.dart index 261188e..27b5fcb 100755 --- a/lib/singbox/service/ffi_singbox_service.dart +++ b/lib/singbox/service/ffi_singbox_service.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; -import 'package:combine/combine.dart'; +import 'package:kaer_with_panels/utils/isolate_worker.dart'; import 'package:ffi/ffi.dart'; import 'package:fpdart/fpdart.dart'; import 'package:kaer_with_panels/core/model/directories.dart'; @@ -50,6 +50,7 @@ class FFISingboxService with InfraLogger implements SingboxService { @override Future init() async { loggy.debug("initializing"); + _box.setupOnce(NativeApi.initializeApiDLData); _statusReceiver = ReceivePort('service status receiver'); final source = _statusReceiver.asBroadcastStream().map((event) => jsonDecode(event as String)).map(SingboxStatus.fromEvent); _status = ValueConnectableStream.seeded( @@ -60,162 +61,197 @@ class FFISingboxService with InfraLogger implements SingboxService { @override TaskEither setup( - Directories directories, - bool debug, - ) { + Directories directories, + bool debug, + ) { final port = _statusReceiver.sendPort.nativePort; - return TaskEither( - () => CombineWorker().execute( - () { - _box.setupOnce(NativeApi.initializeApiDLData); - final err = _box - .setup( - directories.baseDir.path.toNativeUtf8().cast(), - directories.workingDir.path.toNativeUtf8().cast(), - directories.tempDir.path.toNativeUtf8().cast(), - port, - debug ? 1 : 0, - ) - .cast() - .toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + final baseDir = directories.baseDir.path; + final workingDir = directories.workingDir.path; + final tempDir = directories.tempDir.path; + final debugFlag = debug ? 1 : 0; + + return TaskEither(() async { + try { + final startTime = DateTime.now(); + _logger.debug('[黑屏调试] setup() 开始调用 libcore.dll - $startTime'); + + final err = await IsolateWorker().execute( + () => _ffiSetup(baseDir, workingDir, tempDir, port, debugFlag), + allowSyncFallback: false, + ); + + final endTime = DateTime.now(); + final durationMs = endTime.difference(startTime).inMilliseconds; + _logger.debug('[黑屏调试] setup() 完成(耗时: ${durationMs}ms)'); + + if (err != null && err.isNotEmpty) { + _logger.error('[黑屏调试] setup() 错误: $err'); + return left(err); + } + return right(unit); + } catch (e) { + _logger.error('[黑屏调试] setup() 异常: $e'); + return left(e.toString()); + } + }); } @override TaskEither validateConfigByPath( - String path, - String tempPath, - bool debug, - ) { - return TaskEither( - () => CombineWorker().execute( - () { - final err = _box - .parse( - path.toNativeUtf8().cast(), - tempPath.toNativeUtf8().cast(), - debug ? 1 : 0, - ) - .cast() - .toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + String path, + String tempPath, + bool debug, + ) { + final debugFlag = debug ? 1 : 0; + return TaskEither(() async { + try { + final err = await IsolateWorker().execute( + () => _ffiValidateConfig(path, tempPath, debugFlag), + allowSyncFallback: false, + ); + if (err != null && err.isNotEmpty) { + return left(err); + } + return right(unit); + } catch (e) { + return left(e.toString()); + } + }); } @override TaskEither changeOptions(SingboxConfigOption options) { - return TaskEither( - () => CombineWorker().execute( - () { - final json = jsonEncode(options.toJson()); - final err = _box.changeHiddifyOptions(json.toNativeUtf8().cast()).cast().toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + final json = jsonEncode(options.toJson()); + return TaskEither(() async { + try { + final startTime = DateTime.now(); + _logger.debug('[黑屏调试] changeOptions 开始调用 libcore.dll - $startTime'); + + final err = await IsolateWorker().execute( + () => _ffiChangeOptions(json), + allowSyncFallback: false, + ); + + final endTime = DateTime.now(); + final durationMs = endTime.difference(startTime).inMilliseconds; + _logger.debug('[黑屏调试] changeOptions 完成(耗时: ${durationMs}ms)'); + + if (err != null && err.isNotEmpty) { + _logger.error('[黑屏调试] changeOptions 错误: $err'); + return left(err); + } + return right(unit); + } catch (e) { + _logger.error('[黑屏调试] changeOptions 异常: $e'); + return left(e.toString()); + } + }); } @override TaskEither generateFullConfigByPath( - String path, - ) { - return TaskEither( - () => CombineWorker().execute( - () { - final response = _box - .generateConfig( - path.toNativeUtf8().cast(), - ) - .cast() - .toDartString(); - if (response.startsWith("error")) { - return left(response.replaceFirst("error", "")); - } - return right(response); - }, - ), - ); + String path, + ) { + return TaskEither(() async { + try { + final result = await IsolateWorker().execute( + () => _ffiGenerateFullConfig(path), + allowSyncFallback: false, + ); + final ok = result.isNotEmpty && result[0] == true; + final payload = result.length > 1 ? result[1] as String : ''; + if (!ok) { + return left(payload); + } + return right(payload); + } catch (e) { + return left(e.toString()); + } + }); } @override TaskEither start( - String configPath, - String name, - bool disableMemoryLimit, - ) { + String configPath, + String name, + bool disableMemoryLimit, + ) { loggy.debug("starting, memory limit: [${!disableMemoryLimit}]"); - return TaskEither( - () => CombineWorker().execute( - () { - final err = _box - .start( - configPath.toNativeUtf8().cast(), - disableMemoryLimit ? 1 : 0, - ) - .cast() - .toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + return TaskEither(() async { + try { + final startTime = DateTime.now(); + _logger.debug('[黑屏调试] start() 开始调用 libcore.dll - $startTime'); + + final err = await IsolateWorker().execute( + () => _ffiStart(configPath, disableMemoryLimit), + allowSyncFallback: false, + ); + + final endTime = DateTime.now(); + final durationMs = endTime.difference(startTime).inMilliseconds; + _logger.debug('[黑屏调试] start() 完成(耗时: ${durationMs}ms)'); + + if (err != null && err.isNotEmpty) { + _logger.error('[黑屏调试] start() 错误: $err'); + return left(err); + } + return right(unit); + } catch (e) { + _logger.error('[黑屏调试] start() 异常: $e'); + return left(e.toString()); + } + }); } @override TaskEither stop() { - return TaskEither( - () => CombineWorker().execute( - () { - final err = _box.stop().cast().toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + return TaskEither(() async { + try { + final startTime = DateTime.now(); + _logger.debug('[黑屏调试] stop() 开始调用 libcore.dll - $startTime'); + + final err = await IsolateWorker().execute( + _ffiStop, + allowSyncFallback: false, + ); + + final endTime = DateTime.now(); + final durationMs = endTime.difference(startTime).inMilliseconds; + _logger.debug('[黑屏调试] stop() 完成(耗时: ${durationMs}ms)'); + + if (err != null && err.isNotEmpty) { + _logger.error('[黑屏调试] stop() 错误: $err'); + return left(err); + } + return right(unit); + } catch (e) { + _logger.error('[黑屏调试] stop() 异常: $e'); + return left(e.toString()); + } + }); } @override TaskEither restart( - String configPath, - String name, - bool disableMemoryLimit, - ) { + String configPath, + String name, + bool disableMemoryLimit, + ) { loggy.debug("restarting, memory limit: [${!disableMemoryLimit}]"); - return TaskEither( - () => CombineWorker().execute( - () { - final err = _box - .restart( - configPath.toNativeUtf8().cast(), - disableMemoryLimit ? 1 : 0, - ) - .cast() - .toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + return TaskEither(() async { + try { + final err = await IsolateWorker().execute( + () => _ffiRestart(configPath, disableMemoryLimit), + allowSyncFallback: false, + ); + if (err != null && err.isNotEmpty) { + return left(err); + } + return right(unit); + } catch (e) { + return left(e.toString()); + } + }); } @override @@ -243,7 +279,7 @@ class FFISingboxService with InfraLogger implements SingboxService { _serviceStatsStream = null; }, ).map( - (event) { + (event) { if (event case String _) { if (event.startsWith('error:')) { loggy.error("[service stats client] error received: $event"); @@ -283,7 +319,7 @@ class FFISingboxService with InfraLogger implements SingboxService { } }, ).map( - (event) { + (event) { if (event case String _) { if (event.startsWith('error:')) { logger.error("error received: $event"); @@ -327,7 +363,7 @@ class FFISingboxService with InfraLogger implements SingboxService { } }, ).map( - (event) { + (event) { if (event case String _) { if (event.startsWith('error:')) { logger.error(event); @@ -359,38 +395,38 @@ class FFISingboxService with InfraLogger implements SingboxService { @override TaskEither selectOutbound(String groupTag, String outboundTag) { - return TaskEither( - () => CombineWorker().execute( - () { - final err = _box - .selectOutbound( - groupTag.toNativeUtf8().cast(), - outboundTag.toNativeUtf8().cast(), - ) - .cast() - .toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + return TaskEither(() async { + try { + final err = await IsolateWorker().execute( + () => _ffiSelectOutbound(groupTag, outboundTag), + allowSyncFallback: false, + ); + if (err != null && err.isNotEmpty) { + return left(err); + } + return right(unit); + } catch (e) { + return left(e.toString()); + } + }); } @override TaskEither urlTest(String groupTag) { - return TaskEither( - () => CombineWorker().execute( - () { - final err = _box.urlTest(groupTag.toNativeUtf8().cast()).cast().toDartString(); - if (err.isNotEmpty) { - return left(err); - } - return right(unit); - }, - ), - ); + return TaskEither(() async { + try { + final err = await IsolateWorker().execute( + () => _ffiUrlTest(groupTag), + allowSyncFallback: false, + ); + if (err != null && err.isNotEmpty) { + return left(err); + } + return right(unit); + } catch (e) { + return left(e.toString()); + } + }); } final _logBuffer = []; @@ -409,14 +445,10 @@ class FFISingboxService with InfraLogger implements SingboxService { @override TaskEither clearLogs() { - return TaskEither( - () => CombineWorker().execute( - () { - _logBuffer.clear(); - return right(unit); - }, - ), - ); + return TaskEither(() async { + _logBuffer.clear(); + return right(unit); + }); } Future> _readLogFile(File file) async { @@ -443,23 +475,165 @@ class FFISingboxService with InfraLogger implements SingboxService { required String previousAccessToken, }) { loggy.debug("generating warp config"); - return TaskEither( - () => CombineWorker().execute( - () { - final response = _box - .generateWarpConfig( - licenseKey.toNativeUtf8().cast(), - previousAccountId.toNativeUtf8().cast(), - previousAccessToken.toNativeUtf8().cast(), - ) - .cast() - .toDartString(); - if (response.startsWith("error:")) { - return left(response.replaceFirst('error:', "")); - } - return right(warpFromJson(jsonDecode(response))); - }, - ), - ); + return TaskEither(() async { + try { + final result = await IsolateWorker().execute( + () => _ffiGenerateWarpConfig(licenseKey, previousAccountId, previousAccessToken), + allowSyncFallback: false, + ); + final ok = result.isNotEmpty && result[0] == true; + final payload = result.length > 1 ? result[1] as String : ''; + if (!ok) { + return left(payload); + } + return right(warpFromJson(jsonDecode(payload))); + } catch (e) { + return left(e.toString()); + } + }); } } + +SingboxNativeLibrary _ffiLoadLibrary() { + String fullPath = ""; + if (Platform.environment.containsKey('FLUTTER_TEST')) { + fullPath = "libcore"; + } + if (Platform.isWindows) { + fullPath = p.join(fullPath, "libcore.dll"); + } else if (Platform.isMacOS) { + fullPath = p.join(fullPath, "libcore.dylib"); + } else { + fullPath = p.join(fullPath, "libcore.so"); + } + final lib = DynamicLibrary.open(fullPath); + final box = SingboxNativeLibrary(lib); + box.setupOnce(NativeApi.initializeApiDLData); + return box; +} + +String? _ffiSetup( + String baseDir, + String workingDir, + String tempDir, + int statusPort, + int debugFlag, + ) { + final box = _ffiLoadLibrary(); + final err = box + .setup( + baseDir.toNativeUtf8().cast(), + workingDir.toNativeUtf8().cast(), + tempDir.toNativeUtf8().cast(), + statusPort, + debugFlag, + ) + .cast() + .toDartString(); + return err.isEmpty ? null : err; +} + +String? _ffiValidateConfig( + String path, + String tempPath, + int debugFlag, + ) { + final box = _ffiLoadLibrary(); + final err = box + .parse( + path.toNativeUtf8().cast(), + tempPath.toNativeUtf8().cast(), + debugFlag, + ) + .cast() + .toDartString(); + return err.isEmpty ? null : err; +} + +String? _ffiChangeOptions(String optionsJson) { + final box = _ffiLoadLibrary(); + final err = box.changeHiddifyOptions(optionsJson.toNativeUtf8().cast()).cast().toDartString(); + return err.isEmpty ? null : err; +} + +List _ffiGenerateFullConfig(String path) { + final box = _ffiLoadLibrary(); + final response = box + .generateConfig( + path.toNativeUtf8().cast(), + ) + .cast() + .toDartString(); + if (response.startsWith("error")) { + return [false, response.replaceFirst("error", "")]; + } + return [true, response]; +} + +String? _ffiStart(String configPath, bool disableMemoryLimit) { + final box = _ffiLoadLibrary(); + final err = box + .start( + configPath.toNativeUtf8().cast(), + disableMemoryLimit ? 1 : 0, + ) + .cast() + .toDartString(); + return err.isEmpty ? null : err; +} + +String? _ffiStop() { + final box = _ffiLoadLibrary(); + final err = box.stop().cast().toDartString(); + return err.isEmpty ? null : err; +} + +String? _ffiRestart(String configPath, bool disableMemoryLimit) { + final box = _ffiLoadLibrary(); + final err = box + .restart( + configPath.toNativeUtf8().cast(), + disableMemoryLimit ? 1 : 0, + ) + .cast() + .toDartString(); + return err.isEmpty ? null : err; +} + +String? _ffiSelectOutbound(String groupTag, String outboundTag) { + final box = _ffiLoadLibrary(); + final err = box + .selectOutbound( + groupTag.toNativeUtf8().cast(), + outboundTag.toNativeUtf8().cast(), + ) + .cast() + .toDartString(); + return err.isEmpty ? null : err; +} + +String? _ffiUrlTest(String groupTag) { + final box = _ffiLoadLibrary(); + final err = box.urlTest(groupTag.toNativeUtf8().cast()).cast().toDartString(); + return err.isEmpty ? null : err; +} + +List _ffiGenerateWarpConfig( + String licenseKey, + String previousAccountId, + String previousAccessToken, + ) { + final box = _ffiLoadLibrary(); + final response = box + .generateWarpConfig( + licenseKey.toNativeUtf8().cast(), + previousAccountId.toNativeUtf8().cast(), + previousAccessToken.toNativeUtf8().cast(), + ) + .cast() + .toDartString(); + if (response.startsWith("error:")) { + return [false, response.replaceFirst("error:", "")]; + } + return [true, response]; +} diff --git a/lib/utils/isolate_worker.dart b/lib/utils/isolate_worker.dart new file mode 100644 index 0000000..3e26048 --- /dev/null +++ b/lib/utils/isolate_worker.dart @@ -0,0 +1,21 @@ +import 'dart:async'; +import 'dart:isolate'; + +/// Simple worker that executes functions in a separate isolate. +/// Replacement for combine package's CombineWorker. +class IsolateWorker { + /// Execute a function in a separate isolate and return the result. + /// + /// Note: The function must be a top-level function or a static method, + /// and it cannot capture non-sendable objects from the surrounding scope. + Future execute(T Function() computation, {bool allowSyncFallback = false}) async { + try { + return await Isolate.run(computation); + } catch (e) { + if (!allowSyncFallback) { + rethrow; + } + return computation(); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 8c2feb6..cdee0b2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1517,6 +1517,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + synchronized: + dependency: "direct main" + description: + name: synchronized + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" + url: "https://pub.dev" + source: hosted + version: "3.3.0+3" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cc68979..6f2fe86 100755 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dependencies: fpdart: ^1.1.0 dartx: ^1.2.0 rxdart: ^0.27.7 + synchronized: ^3.0.1 # 🔧 P0-4: 用于线程安全的互斥锁保护 combine: 0.5.7 # 精确版本,兼容 Flutter 3.24.3(与 hiddify-app 相同) encrypt: ^5.0.0 path: ^1.8.3