From 4e44d79556fe162a2bcddf28fb9cbb89e3754e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E6=88=90=E9=BE=99?= Date: Fri, 6 Mar 2026 09:41:32 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E5=9C=A8MacOS=E9=A1=B6=E9=83=A8?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=E4=B8=ADFClash=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E4=BB=A3=E7=90=86=E8=8A=82=E7=82=B9=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=AD=E6=B7=BB=E5=8A=A0=E5=BB=B6=E8=BF=9F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/tray.dart | 202 ++++++++++++++++++++++++++++-- lib/controller.dart | 49 ++++++++ lib/l10n/intl/messages_en.dart | 1 + lib/l10n/intl/messages_ja.dart | 1 + lib/l10n/intl/messages_ru.dart | 1 + lib/l10n/intl/messages_zh_CN.dart | 1 + lib/l10n/l10n.dart | 5 + lib/manager/tray_manager.dart | 6 + 8 files changed, 253 insertions(+), 13 deletions(-) diff --git a/lib/common/tray.dart b/lib/common/tray.dart index dfff0931ee..f52442f1bd 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,12 @@ import 'window.dart'; class Tray { static Tray? _instance; + bool _keepMenuOpen = false; + bool _pendingReopenOnClose = false; + int _keepMenuOpenSessionId = 0; + final Set _delayTriggeredGroups = {}; + final Map _proxyMenuItemIdMap = {}; + final Map _groupDelayActionItemIdMap = {}; Tray._internal(); @@ -69,6 +76,8 @@ class Tray { tunEnable: trayState.tunEnable, ); } + _proxyMenuItemIdMap.clear(); + _groupDelayActionItemIdMap.clear(); List menuItems = []; final showMenuItem = MenuItem( label: appLocalizations.show, @@ -111,21 +120,41 @@ class Tray { if (system.isMacOS) { for (final group in trayState.groups) { List subMenuItems = []; + final testUrl = group.testUrl; + 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()); 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 proxyItem = MenuItem.checkbox( + // 在 macOS 托盘菜单中直观展示当前测速结果。 + label: _buildProxyMenuLabel(proxy, testUrl: testUrl), + checked: appController.getSelectedProxyName(group.name) == 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 +220,10 @@ class Tray { ); } updateTrayTitle(showTrayTitle: trayState.showTrayTitle, traffic: traffic); + if (_keepMenuOpen) { + // 菜单刷新会导致系统收起,这里登记一次“下次关闭后重开”。 + _pendingReopenOnClose = true; + } } Future updateTrayTitle({ @@ -216,6 +249,149 @@ class Tray { await Clipboard.setData(ClipboardData(text: cmdline)); } + + void _startDelayTestAndKeepMenuOpen(List groups) { + final sessionId = ++_keepMenuOpenSessionId; + _keepMenuOpen = true; + _pendingReopenOnClose = true; + for (final group in groups) { + _delayTriggeredGroups.add(group.name); + unawaited(_updateDelayActionLabel(group)); + for (final proxy in group.all) { + 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; + } + unawaited( + _updateProxyMenuLabel( + groupName: group.name, + proxyName: proxyName, + testUrl: group.testUrl, + ), + ); + } + }, + ); + } finally { + if (_keepMenuOpenSessionId == sessionId) { + _keepMenuOpen = false; + _pendingReopenOnClose = false; + } + } + }()); + } + + void handleMenuDidClose() { + if (!system.isMacOS || !_keepMenuOpen || !_pendingReopenOnClose) { + return; + } + _pendingReopenOnClose = false; + _reopenMenuIfSessionValid(_keepMenuOpenSessionId); + } + + void _reopenMenuIfSessionValid(int sessionId) { + if (!system.isMacOS || !_keepMenuOpen || _keepMenuOpenSessionId != sessionId) { + return; + } + unawaited(trayManager.popUpContextMenu()); + } + + 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), + ); + } + + String _buildProxyMenuLabel(Proxy proxy, {String? testUrl}) { + return _buildProxyMenuLabelByName(proxy.name, testUrl: testUrl); + } + + String _buildProxyMenuLabelByName(String proxyName, {String? testUrl}) { + 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(); From c8471b42f4482c90c29e5bed87dba80871c95a4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E6=88=90=E9=BE=99?= Date: Fri, 6 Mar 2026 17:13:28 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E6=B5=8B=E9=80=9F=E5=85=B3=E9=97=AD=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E8=8F=9C=E5=8D=95=E4=B9=8B=E5=90=8E=E5=8F=88=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/tray.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/common/tray.dart b/lib/common/tray.dart index f52442f1bd..a17468eba6 100644 --- a/lib/common/tray.dart +++ b/lib/common/tray.dart @@ -253,7 +253,7 @@ class Tray { void _startDelayTestAndKeepMenuOpen(List groups) { final sessionId = ++_keepMenuOpenSessionId; _keepMenuOpen = true; - _pendingReopenOnClose = true; + _pendingReopenOnClose = false; for (final group in groups) { _delayTriggeredGroups.add(group.name); unawaited(_updateDelayActionLabel(group)); @@ -298,7 +298,11 @@ class Tray { } void handleMenuDidClose() { - if (!system.isMacOS || !_keepMenuOpen || !_pendingReopenOnClose) { + if (!system.isMacOS || !_keepMenuOpen) { + return; + } + if (!_pendingReopenOnClose) { + _cancelKeepMenuOpen(); return; } _pendingReopenOnClose = false; @@ -312,6 +316,11 @@ class Tray { unawaited(trayManager.popUpContextMenu()); } + void _cancelKeepMenuOpen() { + _keepMenuOpen = false; + _pendingReopenOnClose = false; + } + String _buildProxyKey({ required String groupName, required String proxyName, From c0a613a21fd5a6dc9e91abe5d63d2175ae0398d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E6=88=90=E9=BE=99?= Date: Fri, 6 Mar 2026 17:35:27 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E7=82=B9=E5=87=BB=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=9A=84=E6=97=B6=E5=80=99=E5=BA=94=E8=AF=A5=E8=AE=A9?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=80=89=E4=B8=AD=E7=9A=84=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=9C=A8=E7=AC=AC=E4=B8=80=E4=B8=AA=E6=96=B9?= =?UTF-8?q?=E4=BE=BF=E6=9F=A5=E7=9C=8B=E5=BD=93=E5=89=8D=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E7=9A=84=E7=BD=91=E7=BB=9C=E5=BB=B6=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/compute.dart | 35 +++++++++++--- lib/common/tray.dart | 103 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 125 insertions(+), 13 deletions(-) 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 a17468eba6..05903236b0 100644 --- a/lib/common/tray.dart +++ b/lib/common/tray.dart @@ -19,6 +19,7 @@ class Tray { bool _pendingReopenOnClose = false; int _keepMenuOpenSessionId = 0; final Set _delayTriggeredGroups = {}; + final Set _testingProxyMenuKeys = {}; final Map _proxyMenuItemIdMap = {}; final Map _groupDelayActionItemIdMap = {}; @@ -121,6 +122,7 @@ class Tray { for (final group in trayState.groups) { List subMenuItems = []; final testUrl = group.testUrl; + final selectedProxyName = appController.getSelectedProxyName(group.name); final hasDelayResult = _hasDelayResultForGroup(group) || _delayTriggeredGroups.contains(group.name); @@ -136,11 +138,19 @@ class Tray { subMenuItems.add(delayActionItem); _groupDelayActionItemIdMap[group.name] = delayActionItem.id; subMenuItems.add(MenuItem.separator()); - for (final proxy in group.all) { + final orderedProxies = _sortProxiesForTray( + proxies: group.all, + selectedProxyName: selectedProxyName, + ); + for (final proxy in orderedProxies) { final proxyItem = MenuItem.checkbox( // 在 macOS 托盘菜单中直观展示当前测速结果。 - label: _buildProxyMenuLabel(proxy, testUrl: testUrl), - checked: appController.getSelectedProxyName(group.name) == proxy.name, + label: _buildProxyMenuLabel( + proxy, + groupName: group.name, + testUrl: testUrl, + ), + checked: selectedProxyName == proxy.name, onClick: (_) { appController.updateCurrentSelectedMap(group.name, proxy.name); appController.changeProxy( @@ -258,6 +268,13 @@ class Tray { _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, @@ -278,6 +295,13 @@ class Tray { if (group.testUrl != testUrl) { continue; } + _testingProxyMenuKeys.remove( + _buildProxyKey( + groupName: group.name, + proxyName: proxyName, + testUrl: group.testUrl, + ), + ); unawaited( _updateProxyMenuLabel( groupName: group.name, @@ -293,6 +317,24 @@ class Tray { _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, + ), + ); + } + } } }()); } @@ -321,6 +363,26 @@ class Tray { _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, @@ -359,15 +421,42 @@ class Tray { } await trayManager.updateMenuItemLabel( id: itemId, - label: _buildProxyMenuLabelByName(proxyName, testUrl: testUrl), + label: _buildProxyMenuLabelByName( + proxyName, + testUrl: testUrl, + testingKey: _buildProxyKey( + groupName: groupName, + proxyName: proxyName, + testUrl: testUrl, + ), + ), ); } - String _buildProxyMenuLabel(Proxy proxy, {String? testUrl}) { - return _buildProxyMenuLabelByName(proxy.name, 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 _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;