diff --git a/core/src/main/cpp/main.c b/core/src/main/cpp/main.c index 914bf7d9b6..2a367d8204 100644 --- a/core/src/main/cpp/main.c +++ b/core/src/main/cpp/main.c @@ -185,8 +185,8 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeQueryGroup(JNIEnv *env, job JNIEXPORT void JNICALL Java_com_github_kr328_clash_core_bridge_Bridge_nativeHealthCheck(JNIEnv *env, jobject thiz, - jobject completable, - jstring name) { + jobject completable, + jstring name) { TRACE_METHOD(); jobject _completable = new_global(completable); @@ -195,6 +195,20 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeHealthCheck(JNIEnv *env, jo healthCheck(_completable, _name); } +JNIEXPORT void JNICALL +Java_com_github_kr328_clash_core_bridge_Bridge_nativeHealthCheckProxy(JNIEnv *env, jobject thiz, + jobject completable, + jstring groupName, + jstring proxyName) { + TRACE_METHOD(); + + jobject _completable = new_global(completable); + scoped_string _groupName = get_string(groupName); + scoped_string _proxyName = get_string(proxyName); + + healthCheckProxy(_completable, _groupName, _proxyName); +} + JNIEXPORT void JNICALL Java_com_github_kr328_clash_core_bridge_Bridge_nativeHealthCheckAll(JNIEnv *env, jobject thiz) { TRACE_METHOD(); @@ -526,4 +540,4 @@ Java_com_github_kr328_clash_core_bridge_Bridge_nativeCoreVersion(JNIEnv *env, jo char* Version = make_String(GIT_VERSION); return new_string(Version); -} \ No newline at end of file +} diff --git a/core/src/main/golang/native/tunnel.go b/core/src/main/golang/native/tunnel.go index 3e48863c00..f4bc7f8ae7 100644 --- a/core/src/main/golang/native/tunnel.go +++ b/core/src/main/golang/native/tunnel.go @@ -74,6 +74,15 @@ func healthCheck(completable unsafe.Pointer, name C.c_string) { }(C.GoString(name)) } +//export healthCheckProxy +func healthCheckProxy(completable unsafe.Pointer, groupName C.c_string, proxyName C.c_string) { + go func(groupName string, proxyName string) { + tunnel.HealthCheckProxy(groupName, proxyName) + + C.complete(completable, nil) + }(C.GoString(groupName), C.GoString(proxyName)) +} + //export healthCheckAll func healthCheckAll() { tunnel.HealthCheckAll() diff --git a/core/src/main/golang/native/tunnel/connectivity.go b/core/src/main/golang/native/tunnel/connectivity.go index be716784ea..26dd1be97f 100644 --- a/core/src/main/golang/native/tunnel/connectivity.go +++ b/core/src/main/golang/native/tunnel/connectivity.go @@ -1,14 +1,42 @@ package tunnel import ( + "context" "sync" + "time" "github.com/metacubex/mihomo/adapter/outboundgroup" + C "github.com/metacubex/mihomo/constant" "github.com/metacubex/mihomo/constant/provider" "github.com/metacubex/mihomo/log" "github.com/metacubex/mihomo/tunnel" ) +const healthCheckTimeout = 5 * time.Second +const defaultHealthCheckURL = "https://www.gstatic.com/generate_204" + +func probeURL(proxy C.Proxy, proxyName string) { + testURL := defaultHealthCheckURL + for k := range proxy.ExtraDelayHistories() { + if len(k) > 0 { + testURL = k + break + } + } + + ctx, cancel := context.WithTimeout(context.Background(), healthCheckTimeout) + defer cancel() + + if _, _, err := proxy.URLTest(ctx, testURL, nil); err != nil && ctx.Err() == nil { + log.Warnln( + "Request health check for `%s` with url `%s` failed: %s", + proxyName, + testURL, + err.Error(), + ) + } +} + func HealthCheck(name string) { p := tunnel.Proxies()[name] @@ -20,7 +48,7 @@ func HealthCheck(name string) { g, ok := p.Adapter().(outboundgroup.ProxyGroup) if !ok { - log.Warnln("Request health check for `%s`: invalid type %s", name, p.Type().String()) + probeURL(p, name) return } @@ -47,3 +75,39 @@ func HealthCheckAll() { }(g) } } + +func HealthCheckProxy(groupName string, proxyName string) { + p := tunnel.Proxies()[groupName] + + if p == nil { + log.Warnln( + "Request health check for proxy `%s` in group `%s`: group not found", + proxyName, + groupName, + ) + return + } + + g, ok := p.Adapter().(outboundgroup.ProxyGroup) + if !ok { + log.Warnln( + "Request health check for proxy `%s` in group `%s`: not a proxy group", + proxyName, + groupName, + ) + return + } + + for _, proxy := range g.Proxies() { + if proxy.Name() == proxyName { + probeURL(proxy, proxyName) + return + } + } + + log.Warnln( + "Request health check for proxy `%s` in group `%s`: proxy not found", + proxyName, + groupName, + ) +} diff --git a/core/src/main/kotlin/com/github/kr328/clash/core/Clash.kt b/core/src/main/kotlin/com/github/kr328/clash/core/Clash.kt index 33d01c24cc..361d9890dc 100644 --- a/core/src/main/kotlin/com/github/kr328/clash/core/Clash.kt +++ b/core/src/main/kotlin/com/github/kr328/clash/core/Clash.kt @@ -133,6 +133,12 @@ object Clash { return CompletableDeferred().apply { Bridge.nativeHealthCheck(this, name) } } + fun healthCheckProxy(groupName: String, proxyName: String): CompletableDeferred { + return CompletableDeferred().apply { + Bridge.nativeHealthCheckProxy(this, groupName, proxyName) + } + } + fun healthCheckAll() { Bridge.nativeHealthCheckAll() } diff --git a/core/src/main/kotlin/com/github/kr328/clash/core/bridge/Bridge.kt b/core/src/main/kotlin/com/github/kr328/clash/core/bridge/Bridge.kt index f537891652..502f6217e8 100644 --- a/core/src/main/kotlin/com/github/kr328/clash/core/bridge/Bridge.kt +++ b/core/src/main/kotlin/com/github/kr328/clash/core/bridge/Bridge.kt @@ -48,6 +48,11 @@ object Bridge { external fun nativeQueryGroup(name: String, sort: String): String? external fun nativeHealthCheck(completable: CompletableDeferred, name: String) + external fun nativeHealthCheckProxy( + completable: CompletableDeferred, + groupName: String, + proxyName: String, + ) external fun nativeHealthCheckAll() diff --git a/service/src/main/kotlin/com/github/kr328/clash/service/ClashManager.kt b/service/src/main/kotlin/com/github/kr328/clash/service/ClashManager.kt index 9e3a0c5f02..e680016141 100644 --- a/service/src/main/kotlin/com/github/kr328/clash/service/ClashManager.kt +++ b/service/src/main/kotlin/com/github/kr328/clash/service/ClashManager.kt @@ -86,6 +86,10 @@ class ClashManager(private val context: Context) : return Clash.healthCheck(group).await() } + override suspend fun healthCheckProxy(group: String, name: String) { + return Clash.healthCheckProxy(group, name).await() + } + override suspend fun updateProvider(type: Provider.Type, name: String) { return Clash.updateProvider(type, name).await() } diff --git a/service/src/main/kotlin/com/github/kr328/clash/service/remote/IClashManager.kt b/service/src/main/kotlin/com/github/kr328/clash/service/remote/IClashManager.kt index 71f63af396..a238d3442a 100644 --- a/service/src/main/kotlin/com/github/kr328/clash/service/remote/IClashManager.kt +++ b/service/src/main/kotlin/com/github/kr328/clash/service/remote/IClashManager.kt @@ -28,6 +28,7 @@ interface IClashManager { fun patchSelector(group: String, name: String): Boolean suspend fun healthCheck(group: String) + suspend fun healthCheckProxy(group: String, name: String) suspend fun updateProvider(type: Provider.Type, name: String) diff --git a/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt b/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt index 3288e7a486..e1287778bf 100644 --- a/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt +++ b/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/ui/ProxyScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -106,6 +107,7 @@ internal fun ProxyScreen( onProxySortChanged = viewModel::onProxySortChanged, onOverrideModeSelected = viewModel::onOverrideModeSelected, onProxySelected = viewModel::onProxySelected, + onProxyDelayTest = viewModel::onProxyDelayTest, ) } @@ -122,6 +124,7 @@ private fun ProxyContent( onProxySortChanged: (ProxySort) -> Unit, onOverrideModeSelected: (TunnelState.Mode?) -> Unit, onProxySelected: (Int, String) -> Unit, + onProxyDelayTest: (Int, String) -> Unit, ) { var menuVisible by remember { mutableStateOf(false) } val currentGroup = uiState.groups.getOrNull(uiState.currentPage) @@ -200,6 +203,7 @@ private fun ProxyContent( selectedProxies = selectedProxies, onPageChanged = onPageChanged, onProxySelected = onProxySelected, + onProxyDelayTest = onProxyDelayTest, ) } } @@ -212,6 +216,7 @@ private fun ProxyPagerContent( selectedProxies: List, onPageChanged: (Int) -> Unit, onProxySelected: (Int, String) -> Unit, + onProxyDelayTest: (Int, String) -> Unit, ) { val groupNames = uiState.groupNames if (groupNames.isEmpty()) return @@ -247,6 +252,7 @@ private fun ProxyPagerContent( group = uiState.groups.getOrNull(page) ?: ProxyViewModel.UiState.ProxyGroupUiState(), selectedProxies = selectedProxies, onProxySelected = onProxySelected, + onProxyDelayTest = onProxyDelayTest, ) } } @@ -259,6 +265,7 @@ private fun ProxyGroupPage( group: ProxyViewModel.UiState.ProxyGroupUiState, selectedProxies: List, onProxySelected: (Int, String) -> Unit, + onProxyDelayTest: (Int, String) -> Unit, ) { val sources = group.sources val refreshVersion = group.refreshVersion @@ -285,10 +292,11 @@ private fun ProxyGroupPage( linkNow = linkNow, proxyLine = proxyLine, selectedControl = selectedControl, - selectedBackground = selectedBackground, - unselectedControl = unselectedControl, - unselectedBackground = unselectedBackground, - ) + selectedBackground = selectedBackground, + unselectedControl = unselectedControl, + unselectedBackground = unselectedBackground, + delayTesting = source.proxy.name in group.delayTestingKeys, + ) } ProxyItemCard( @@ -296,6 +304,7 @@ private fun ProxyGroupPage( proxyLine = proxyLine, selectable = group.selectable, onClick = { onProxySelected(index, item.key) }, + onDelayClick = { onProxyDelayTest(index, item.key) }, ) } } @@ -307,6 +316,7 @@ private fun ProxyItemCard( proxyLine: Int, selectable: Boolean, onClick: () -> Unit, + onDelayClick: () -> Unit, ) { val shape = RoundedCornerShape(if (proxyLine == 1) 0.dp else 5.dp) val modifier = @@ -342,14 +352,18 @@ private fun ProxyItemCard( ) } - if (item.delayText.isNotEmpty()) { - Text( - text = item.delayText, - color = item.controls, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - ) - } + val badgeText = if (item.delayTesting) delayTestingPlaceholder else item.delayText + Text( + modifier = + Modifier.clip(CircleShape) + .clickable(onClick = onDelayClick) + .background(item.controls.copy(alpha = if (item.delayTesting) 0.33f else 0.14f)) + .padding(horizontal = 8.dp, vertical = 2.dp), + text = badgeText, + color = item.controls, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) } } @@ -479,6 +493,7 @@ private fun ProxyMenuRadioRow(title: String, selected: Boolean, onClick: () -> U } private val gridContentPadding = 12.dp +private const val delayTestingPlaceholder = "ยทยทยท" private fun columnsForProxyLine(proxyLine: Int): Int = when (proxyLine) { @@ -563,5 +578,6 @@ private fun ProxyContentPreview() { onProxySortChanged = {}, onOverrideModeSelected = {}, onProxySelected = { _, _ -> }, + onProxyDelayTest = { _, _ -> }, ) } diff --git a/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/vm/ProxyViewModel.kt b/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/vm/ProxyViewModel.kt index a9be986d23..0dd0874617 100644 --- a/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/vm/ProxyViewModel.kt +++ b/ui/proxy/src/main/kotlin/com/github/kr328/clash/proxy/vm/ProxyViewModel.kt @@ -173,6 +173,29 @@ internal class ProxyViewModel(app: Application) : AndroidViewModel(app), Default } } + fun onProxyDelayTest(index: Int, name: String) { + val names = uiState.value.groupNames + if (index !in names.indices) return + + updateGroupState(index) { + it.copy(delayTestingKeys = it.delayTestingKeys + name, refreshVersion = it.refreshVersion + 1) + } + + viewModelScope.launch { + try { + withClash { healthCheckProxy(names[index], name) } + reload(index) + } finally { + updateGroupState(index) { + it.copy( + delayTestingKeys = it.delayTestingKeys - name, + refreshVersion = it.refreshVersion + 1, + ) + } + } + } + } + fun reloadAll() { val names = uiState.value.groupNames names.indices.forEach { idx -> reload(idx) } @@ -207,6 +230,7 @@ internal class ProxyViewModel(app: Application) : AndroidViewModel(app), Default selectable = group.type == Proxy.Type.Selector, urlTesting = false, sources = sources, + delayTestingKeys = it.delayTestingKeys.intersect(sources.mapTo(mutableSetOf()) { s -> s.proxy.name }), refreshVersion = it.refreshVersion + 1, ) } @@ -239,6 +263,7 @@ internal class ProxyViewModel(app: Application) : AndroidViewModel(app), Default val selectable: Boolean = false, val urlTesting: Boolean = false, val sources: List = emptyList(), + val delayTestingKeys: Set = emptySet(), val refreshVersion: Int = 0, ) @@ -251,6 +276,7 @@ internal class ProxyViewModel(app: Application) : AndroidViewModel(app), Default selectedBackground: Color, unselectedControl: Color, unselectedBackground: Color, + delayTesting: Boolean, ): ProxyItemUiState { val selected = proxy.name == parentNow?.name val background = @@ -278,7 +304,8 @@ internal class ProxyViewModel(app: Application) : AndroidViewModel(app), Default key = proxy.name, title = title, subtitle = subtitle, - delayText = if (proxy.delay in 0..Short.MAX_VALUE) proxy.delay.toString() else "", + delayText = if (proxy.delay in 0..Short.MAX_VALUE) proxy.delay.toString() else "--", + delayTesting = delayTesting, selected = selected, background = background, controls = controls, @@ -291,6 +318,7 @@ internal class ProxyViewModel(app: Application) : AndroidViewModel(app), Default val title: String, val subtitle: String, val delayText: String, + val delayTesting: Boolean, val selected: Boolean, val background: Color, val controls: Color,