diff --git a/lib/common/compute.dart b/lib/common/compute.dart index 1564a72132..4949623180 100644 --- a/lib/common/compute.dart +++ b/lib/common/compute.dart @@ -39,16 +39,39 @@ List computeSort({ return List.of(proxies)..sort((a, b) => a.name.compareTo(b.name)); } + List moveSelectedProxyToFirst({ + required List proxies, + required String? selectedProxyName, + }) { + if (selectedProxyName == null || selectedProxyName.isEmpty) { + return proxies; + } + return List.from(proxies)..sort((a, b) { + if (a.name == selectedProxyName && b.name != selectedProxyName) { + return -1; + } + if (b.name == selectedProxyName && a.name != selectedProxyName) { + return 1; + } + return 0; + }); + } + return groups.map((group) { final proxies = group.all; final newProxies = switch (sortType) { ProxiesSortType.none => proxies, - ProxiesSortType.delay => sortOfDelay( - groups: groups, - proxies: proxies, - delayMap: delayMap, - selectedMap: selectedMap, - testUrl: group.testUrl.takeFirstValid([defaultTestUrl]), + ProxiesSortType.delay => moveSelectedProxyToFirst( + proxies: sortOfDelay( + groups: groups, + proxies: proxies, + delayMap: delayMap, + selectedMap: selectedMap, + testUrl: group.testUrl.takeFirstValid([defaultTestUrl]), + ), + selectedProxyName: group.getCurrentSelectedName( + selectedMap[group.name] ?? '', + ), ), ProxiesSortType.name => sortOfName(proxies), }; diff --git a/lib/common/tray.dart b/lib/common/tray.dart index dfff0931ee..05903236b0 100644 --- a/lib/common/tray.dart +++ b/lib/common/tray.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:fl_clash/controller.dart'; @@ -14,6 +15,13 @@ import 'window.dart'; class Tray { static Tray? _instance; + bool _keepMenuOpen = false; + bool _pendingReopenOnClose = false; + int _keepMenuOpenSessionId = 0; + final Set _delayTriggeredGroups = {}; + final Set _testingProxyMenuKeys = {}; + final Map _proxyMenuItemIdMap = {}; + final Map _groupDelayActionItemIdMap = {}; Tray._internal(); @@ -69,6 +77,8 @@ class Tray { tunEnable: trayState.tunEnable, ); } + _proxyMenuItemIdMap.clear(); + _groupDelayActionItemIdMap.clear(); List menuItems = []; final showMenuItem = MenuItem( label: appLocalizations.show, @@ -111,21 +121,50 @@ class Tray { if (system.isMacOS) { for (final group in trayState.groups) { List subMenuItems = []; - for (final proxy in group.all) { - subMenuItems.add( - MenuItem.checkbox( - label: proxy.name, - checked: - appController.getSelectedProxyName(group.name) == proxy.name, - onClick: (_) { - appController.updateCurrentSelectedMap(group.name, proxy.name); - appController.changeProxy( - groupName: group.name, - proxyName: proxy.name, - ); - }, + final testUrl = group.testUrl; + final selectedProxyName = appController.getSelectedProxyName(group.name); + final hasDelayResult = + _hasDelayResultForGroup(group) || + _delayTriggeredGroups.contains(group.name); + final delayActionItem = MenuItem( + key: 'keep-open:delay-test:${group.name}', + label: _buildDelayTestActionLabel( + hasDelayResult: hasDelayResult, + ), + onClick: (_) { + _startDelayTestAndKeepMenuOpen([group]); + }, + ); + subMenuItems.add(delayActionItem); + _groupDelayActionItemIdMap[group.name] = delayActionItem.id; + subMenuItems.add(MenuItem.separator()); + final orderedProxies = _sortProxiesForTray( + proxies: group.all, + selectedProxyName: selectedProxyName, + ); + for (final proxy in orderedProxies) { + final proxyItem = MenuItem.checkbox( + // 在 macOS 托盘菜单中直观展示当前测速结果。 + label: _buildProxyMenuLabel( + proxy, + groupName: group.name, + testUrl: testUrl, ), + checked: selectedProxyName == proxy.name, + onClick: (_) { + appController.updateCurrentSelectedMap(group.name, proxy.name); + appController.changeProxy( + groupName: group.name, + proxyName: proxy.name, + ); + }, ); + subMenuItems.add(proxyItem); + _proxyMenuItemIdMap[_buildProxyKey( + groupName: group.name, + proxyName: proxy.name, + testUrl: testUrl, + )] = proxyItem.id; } menuItems.add( MenuItem.submenu( @@ -191,6 +230,10 @@ class Tray { ); } updateTrayTitle(showTrayTitle: trayState.showTrayTitle, traffic: traffic); + if (_keepMenuOpen) { + // 菜单刷新会导致系统收起,这里登记一次“下次关闭后重开”。 + _pendingReopenOnClose = true; + } } Future updateTrayTitle({ @@ -216,6 +259,237 @@ class Tray { await Clipboard.setData(ClipboardData(text: cmdline)); } + + void _startDelayTestAndKeepMenuOpen(List groups) { + final sessionId = ++_keepMenuOpenSessionId; + _keepMenuOpen = true; + _pendingReopenOnClose = false; + for (final group in groups) { + _delayTriggeredGroups.add(group.name); + unawaited(_updateDelayActionLabel(group)); + for (final proxy in group.all) { + _testingProxyMenuKeys.add( + _buildProxyKey( + groupName: group.name, + proxyName: proxy.name, + testUrl: group.testUrl, + ), + ); + unawaited( + _updateProxyMenuLabel( + groupName: group.name, + proxyName: proxy.name, + testUrl: group.testUrl, + ), + ); + } + } + unawaited(() async { + try { + await appController.delayTestForTrayGroups( + groups, + refreshTrayOnProgress: false, + refreshTrayOnDone: false, + onDelayUpdated: (proxyName, testUrl) { + for (final group in groups) { + if (group.testUrl != testUrl) { + continue; + } + _testingProxyMenuKeys.remove( + _buildProxyKey( + groupName: group.name, + proxyName: proxyName, + testUrl: group.testUrl, + ), + ); + unawaited( + _updateProxyMenuLabel( + groupName: group.name, + proxyName: proxyName, + testUrl: group.testUrl, + ), + ); + } + }, + ); + } finally { + if (_keepMenuOpenSessionId == sessionId) { + _keepMenuOpen = false; + _pendingReopenOnClose = false; + } + for (final group in groups) { + for (final proxy in group.all) { + _testingProxyMenuKeys.remove( + _buildProxyKey( + groupName: group.name, + proxyName: proxy.name, + testUrl: group.testUrl, + ), + ); + unawaited( + _updateProxyMenuLabel( + groupName: group.name, + proxyName: proxy.name, + testUrl: group.testUrl, + ), + ); + } + } + } + }()); + } + + void handleMenuDidClose() { + if (!system.isMacOS || !_keepMenuOpen) { + return; + } + if (!_pendingReopenOnClose) { + _cancelKeepMenuOpen(); + return; + } + _pendingReopenOnClose = false; + _reopenMenuIfSessionValid(_keepMenuOpenSessionId); + } + + void _reopenMenuIfSessionValid(int sessionId) { + if (!system.isMacOS || !_keepMenuOpen || _keepMenuOpenSessionId != sessionId) { + return; + } + unawaited(trayManager.popUpContextMenu()); + } + + void _cancelKeepMenuOpen() { + _keepMenuOpen = false; + _pendingReopenOnClose = false; + } + + List _sortProxiesForTray({ + required List proxies, + required String? selectedProxyName, + }) { + if (selectedProxyName == null || selectedProxyName.isEmpty) { + return proxies; + } + final sortedProxies = List.from(proxies); + sortedProxies.sort((a, b) { + if (a.name == selectedProxyName && b.name != selectedProxyName) { + return -1; + } + if (b.name == selectedProxyName && a.name != selectedProxyName) { + return 1; + } + return 0; + }); + return sortedProxies; + } + + String _buildProxyKey({ + required String groupName, + required String proxyName, + required String? testUrl, + }) { + return '$groupName|$proxyName|${testUrl ?? ''}'; + } + + Future _updateDelayActionLabel(Group group) async { + final itemId = _groupDelayActionItemIdMap[group.name]; + if (itemId == null) { + return; + } + await trayManager.updateMenuItemLabel( + id: itemId, + label: _buildDelayTestActionLabel( + hasDelayResult: + _hasDelayResultForGroup(group) || + _delayTriggeredGroups.contains(group.name), + ), + ); + } + + Future _updateProxyMenuLabel({ + required String groupName, + required String proxyName, + required String? testUrl, + }) async { + final itemId = _proxyMenuItemIdMap[_buildProxyKey( + groupName: groupName, + proxyName: proxyName, + testUrl: testUrl, + )]; + if (itemId == null) { + return; + } + await trayManager.updateMenuItemLabel( + id: itemId, + label: _buildProxyMenuLabelByName( + proxyName, + testUrl: testUrl, + testingKey: _buildProxyKey( + groupName: groupName, + proxyName: proxyName, + testUrl: testUrl, + ), + ), + ); + } + + String _buildProxyMenuLabel( + Proxy proxy, { + required String groupName, + String? testUrl, + }) { + return _buildProxyMenuLabelByName( + proxy.name, + testUrl: testUrl, + testingKey: _buildProxyKey( + groupName: groupName, + proxyName: proxy.name, + testUrl: testUrl, + ), + ); + } + + String _buildProxyMenuLabelByName( + String proxyName, { + String? testUrl, + String? testingKey, + }) { + if (testingKey != null && _testingProxyMenuKeys.contains(testingKey)) { + return '$proxyName (...)'; + } + final delay = appController.getDelayForProxy(proxyName, testUrl: testUrl); + if (delay == null) { + return proxyName; + } + final delayText = switch (delay) { + 0 => '...', + > 0 => '$delay ms', + _ => 'Timeout', + }; + return '$proxyName ($delayText)'; + } + + bool _hasDelayResultForGroup(Group group) { + for (final proxy in group.all) { + final delay = appController.getDelayForProxy( + proxy.name, + testUrl: group.testUrl, + ); + if (delay != null) { + return true; + } + } + return false; + } + + String _buildDelayTestActionLabel({ + required bool hasDelayResult, + }) { + if (hasDelayResult) { + return appLocalizations.retest; + } + return appLocalizations.delayTest; + } } final tray = system.isDesktop ? Tray() : null; diff --git a/lib/controller.dart b/lib/controller.dart index 7d27f56e4c..2e6ed63e8c 100644 --- a/lib/controller.dart +++ b/lib/controller.dart @@ -492,6 +492,55 @@ extension ProxiesControllerExt on AppController { _ref.read(delayDataSourceProvider.notifier).setDelay(delay); } + int? getDelayForProxy(String proxyName, {String? testUrl}) { + return _ref.read(getDelayProvider(proxyName: proxyName, testUrl: testUrl)); + } + + Future delayTestForTrayGroups( + List groups, { + bool refreshTrayOnProgress = false, + bool refreshTrayOnDone = true, + void Function(String proxyName, String testUrl)? onDelayUpdated, + }) async { + final proxyNames = groups + .expand((group) => group.all.map((proxy) => proxy.name)) + .toSet() + .toList(); + + final delayTasks = proxyNames.map>((proxyName) async { + // 复用现有真实节点解析逻辑,确保分组节点也能拿到正确的测速对象。 + final selectedMap = _ref.read( + currentProfileProvider.select((state) => state?.selectedMap ?? {}), + ); + final state = computeRealSelectedProxyState( + proxyName, + groups: _ref.read(groupsProvider), + selectedMap: selectedMap, + ); + final url = state.testUrl.takeFirstValid([ + _ref.read(appSettingProvider.select((state) => state.testUrl)), + ]); + final name = state.proxyName; + if (name.isEmpty) { + return; + } + setDelay(Delay(url: url, name: name, value: 0)); + onDelayUpdated?.call(name, url); + setDelay(await coreController.getDelay(url, name)); + onDelayUpdated?.call(name, url); + }).toList(); + + for (final batchTasks in delayTasks.batch(100)) { + await Future.wait(batchTasks); + if (refreshTrayOnProgress) { + await updateTray(); + } + } + if (refreshTrayOnDone) { + await updateTray(); + } + } + Future changeProxy({ required String groupName, required String proxyName, diff --git a/lib/l10n/intl/messages_en.dart b/lib/l10n/intl/messages_en.dart index 951298097e..8358688aec 100644 --- a/lib/l10n/intl/messages_en.dart +++ b/lib/l10n/intl/messages_en.dart @@ -271,6 +271,7 @@ class MessageLookup extends MessageLookupByLibrary { "delay": MessageLookupByLibrary.simpleMessage("Delay"), "delaySort": MessageLookupByLibrary.simpleMessage("Sort by delay"), "delayTest": MessageLookupByLibrary.simpleMessage("Delay Test"), + "retest": MessageLookupByLibrary.simpleMessage("Retest"), "delete": MessageLookupByLibrary.simpleMessage("Delete"), "deleteMultipTip": m1, "deleteTip": m2, diff --git a/lib/l10n/intl/messages_ja.dart b/lib/l10n/intl/messages_ja.dart index 89a3994fd5..2c0c573b56 100644 --- a/lib/l10n/intl/messages_ja.dart +++ b/lib/l10n/intl/messages_ja.dart @@ -206,6 +206,7 @@ class MessageLookup extends MessageLookupByLibrary { "delay": MessageLookupByLibrary.simpleMessage("遅延"), "delaySort": MessageLookupByLibrary.simpleMessage("遅延順"), "delayTest": MessageLookupByLibrary.simpleMessage("遅延テスト"), + "retest": MessageLookupByLibrary.simpleMessage("再テスト"), "delete": MessageLookupByLibrary.simpleMessage("削除"), "deleteMultipTip": m1, "deleteTip": m2, diff --git a/lib/l10n/intl/messages_ru.dart b/lib/l10n/intl/messages_ru.dart index 3a55c20db5..427754072f 100644 --- a/lib/l10n/intl/messages_ru.dart +++ b/lib/l10n/intl/messages_ru.dart @@ -278,6 +278,7 @@ class MessageLookup extends MessageLookupByLibrary { "delay": MessageLookupByLibrary.simpleMessage("Задержка"), "delaySort": MessageLookupByLibrary.simpleMessage("Сортировка по задержке"), "delayTest": MessageLookupByLibrary.simpleMessage("Тест задержки"), + "retest": MessageLookupByLibrary.simpleMessage("Повторный тест"), "delete": MessageLookupByLibrary.simpleMessage("Удалить"), "deleteMultipTip": m1, "deleteTip": m2, diff --git a/lib/l10n/intl/messages_zh_CN.dart b/lib/l10n/intl/messages_zh_CN.dart index 73ae31360a..dce89b3474 100644 --- a/lib/l10n/intl/messages_zh_CN.dart +++ b/lib/l10n/intl/messages_zh_CN.dart @@ -186,6 +186,7 @@ class MessageLookup extends MessageLookupByLibrary { "delay": MessageLookupByLibrary.simpleMessage("延迟"), "delaySort": MessageLookupByLibrary.simpleMessage("按延迟排序"), "delayTest": MessageLookupByLibrary.simpleMessage("延迟测试"), + "retest": MessageLookupByLibrary.simpleMessage("重新测速"), "delete": MessageLookupByLibrary.simpleMessage("删除"), "deleteMultipTip": m1, "deleteTip": m2, diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 1a5c357b72..5b7c514d92 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -3748,6 +3748,11 @@ class AppLocalizations { String get delayTest { return Intl.message('Delay Test', name: 'delayTest', desc: '', args: []); } + + /// `Retest` + String get retest { + return Intl.message('Retest', name: 'retest', desc: '', args: []); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/manager/tray_manager.dart b/lib/manager/tray_manager.dart index 998c5c1d26..a7256498fe 100755 --- a/lib/manager/tray_manager.dart +++ b/lib/manager/tray_manager.dart @@ -53,6 +53,12 @@ class _TrayContainerState extends ConsumerState with TrayListener { super.onTrayMenuItemClick(menuItem); } + @override + void onTrayMenuDidClose() { + tray?.handleMenuDidClose(); + super.onTrayMenuDidClose(); + } + @override onTrayIconMouseDown() { window?.show();