From 06dd8ce686d082d6da7dbcc321ad576350321942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 21 May 2026 17:01:27 +0800 Subject: [PATCH] tailscale: Add exit node switcher & peer status --- .../sfa/compose/navigation/SFANavigation.kt | 14 ++ .../screen/tools/TailscaleEndpointScreen.kt | 142 +++++++++++-- .../tools/TailscaleExitNodePickerScreen.kt | 197 ++++++++++++++++++ .../screen/tools/TailscalePeerScreen.kt | 99 ++++++--- .../screen/tools/TailscaleStatusViewModel.kt | 39 +++- app/src/main/res/values-fa/strings.xml | 7 + app/src/main/res/values-ru-rRU/strings.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values-zh-rTW/strings.xml | 7 + app/src/main/res/values/strings.xml | 8 + 10 files changed, 475 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index 6357e5c..c40f03a 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -46,6 +46,7 @@ import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen import io.nekohasekai.sfa.compose.screen.tools.OutboundPickerScreen import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleExitNodePickerScreen import io.nekohasekai.sfa.compose.screen.tools.TailscalePeerScreen import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen @@ -278,6 +279,19 @@ fun SFANavHost( TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag) } + composable( + route = "tools/tailscale/{endpointTag}/exit_node", + arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscaleExitNodePickerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag) + } + composable( route = "tools/tailscale/{endpointTag}/peer/{peerId}", arguments = listOf( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt index 60e3090..c19934f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sfa.compose.screen.tools import android.content.Intent import android.net.Uri +import android.text.format.DateUtils import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -93,6 +94,7 @@ fun TailscaleEndpointScreen( ) { val hasNetwork = endpoint.networkName.isNotEmpty() val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty() + val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates val hasAuth = endpoint.authURL.isNotEmpty() // Status section @@ -106,7 +108,7 @@ fun TailscaleEndpointScreen( ), ) { Column { - val stateIsLast = !hasNetwork && !hasMagicDNS && !hasAuth + val stateIsLast = !hasNetwork && !hasMagicDNS && !hasExitNode && !hasAuth ListItem( headlineContent = { Text( @@ -142,7 +144,7 @@ fun TailscaleEndpointScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) if (hasNetwork) { - val networkIsLast = !hasMagicDNS && !hasAuth + val networkIsLast = !hasMagicDNS && !hasExitNode && !hasAuth ListItem( headlineContent = { Text( @@ -166,7 +168,7 @@ fun TailscaleEndpointScreen( ) } if (hasMagicDNS) { - val magicDNSIsLast = !hasAuth + val magicDNSIsLast = !hasExitNode && !hasAuth ListItem( headlineContent = { Text( @@ -189,6 +191,39 @@ fun TailscaleEndpointScreen( colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } + if (hasExitNode) { + val exitNodeIsLast = !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_exit_node), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + endpoint.exitNode?.hostName ?: stringResource(R.string.disabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + }, + modifier = Modifier + .clip( + if (exitNodeIsLast) { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + } else { + RoundedCornerShape(0.dp) + }, + ) + .clickable { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/exit_node", + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } if (hasAuth) { ListItem( headlineContent = { @@ -323,34 +358,97 @@ private fun PeerItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - ListItem( - headlineContent = { + val badges = peerBadges(peer) + val firstIP = peer.tailscaleIPs.firstOrNull() + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( peer.hostName, style = MaterialTheme.typography.bodyLarge, ) - }, - supportingContent = if (peer.tailscaleIPs.isNotEmpty()) { - { + if (firstIP != null) { Text( - peer.tailscaleIPs.first(), + firstIP, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } else { - null - }, - leadingContent = { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), - ) - }, - modifier = modifier.clickable(onClick = onClick), - colors = ListItemDefaults.colors(containerColor = Color.Transparent), + if (badges.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + for (badge in badges) { + PeerBadgeView(badge) + } + } + } + } + } +} + +private data class PeerBadge(val text: String, val color: Color) + +@Composable +private fun peerBadges(peer: TailscalePeerData): List { + val badges = mutableListOf() + if (peer.shareeNode) { + badges += PeerBadge(stringResource(R.string.tailscale_shared_in), Color(0xFFF44336)) + } + if (peer.exitNodeOption) { + badges += PeerBadge(stringResource(R.string.tailscale_exit_node), Color(0xFF2196F3)) + } + when { + peer.expired -> { + badges += PeerBadge(stringResource(R.string.tailscale_expired), Color(0xFFF44336)) + } + peer.keyExpiry > 0 -> { + val expiryMs = peer.keyExpiry * 1000 + val now = System.currentTimeMillis() + val oneMonthMs = 30L * 24 * 60 * 60 * 1000 + if (expiryMs - now <= oneMonthMs) { + val rel = DateUtils.getRelativeTimeSpanString( + expiryMs, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ).toString() + badges += PeerBadge(stringResource(R.string.tailscale_expires_relative, rel), Color.Gray) + } + } + else -> { + badges += PeerBadge(stringResource(R.string.tailscale_key_expiry_disabled), Color.Gray) + } + } + if (peer.sshHostKeys.isNotEmpty()) { + badges += PeerBadge(stringResource(R.string.tailscale_ssh), Color(0xFF4CAF50)) + } + return badges +} + +@Composable +private fun PeerBadgeView(badge: PeerBadge) { + Text( + text = badge.text, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(badge.color) + .padding(horizontal = 6.dp, vertical = 2.dp), ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt new file mode 100644 index 0000000..ca5107c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt @@ -0,0 +1,197 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleExitNodePickerScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, +) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tailscale_exit_node)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val state by viewModel.uiState.collectAsState() + val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag } + + if (endpoint == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + var searchText by rememberSaveable { mutableStateOf("") } + + val selfStableID = endpoint.selfPeer?.stableID + val candidates = endpoint.userGroups + .flatMap { it.peers } + .filter { it.exitNodeOption && it.stableID != selfStableID } + .filter { searchText.isEmpty() || it.hostName.contains(searchText, ignoreCase = true) } + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(android.R.string.search_go)) }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + DisabledRow( + isSelected = endpoint.exitNode == null, + onClick = { + viewModel.setExitNode(endpointTag, "") + navController.navigateUp() + }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + items(candidates, key = { it.stableID }) { peer -> + PeerRow( + peer = peer, + isSelected = endpoint.exitNode?.stableID == peer.stableID, + onClick = { + viewModel.setExitNode(endpointTag, peer.stableID) + navController.navigateUp() + }, + ) + } + } + } +} + +@Composable +private fun DisabledRow( + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.disabled), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun PeerRow( + peer: TailscalePeerData, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = peer.hostName, + style = MaterialTheme.typography.bodyLarge, + ) + val firstIP = peer.tailscaleIPs.firstOrNull() + if (firstIP != null) { + Text( + text = firstIP, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt index c821186..549ec22 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -329,47 +328,89 @@ fun TailscalePeerScreen( } // Details section - val showDetails = peer.keyExpiry > 0 || peer.os.isNotEmpty() || peer.exitNode - if (showDetails) { - Spacer(modifier = Modifier.height(16.dp)) - SectionHeader(stringResource(R.string.tailscale_details)) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - ), + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_details)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - val context = LocalContext.current - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (peer.keyExpiry > 0) { + when { + peer.expired -> { + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = stringResource(R.string.tailscale_expired), + valueColor = MaterialTheme.colorScheme.error, + ) + } + peer.keyExpiry > 0 -> { val expiryText = DateUtils.getRelativeTimeSpanString( peer.keyExpiry * 1000, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS, + 0, ).toString() DetailRow( label = stringResource(R.string.tailscale_key_expiry), value = expiryText, ) } - if (peer.os.isNotEmpty()) { + else -> { DetailRow( - label = stringResource(R.string.tailscale_os), - value = peer.os, - ) - } - if (peer.exitNode) { - DetailRow( - label = stringResource(R.string.tailscale_exit_node), - value = stringResource(R.string.tailscale_active), + label = stringResource(R.string.tailscale_key_expiry), + value = stringResource(R.string.disabled), + valueColor = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + if (peer.os.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_os), + value = peer.os, + ) + } + if (!peer.online && peer.lastSeen > 0) { + val lastSeenText = DateUtils.getRelativeTimeSpanString( + peer.lastSeen * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + 0, + ).toString() + DetailRow( + label = stringResource(R.string.tailscale_last_seen), + value = lastSeenText, + ) + } + if (peer.exitNode) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_active), + ) + } else if (peer.exitNodeOption) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_available), + ) + } + if (peer.shareeNode) { + DetailRow( + label = stringResource(R.string.tailscale_shared_in), + value = stringResource(R.string.tailscale_yes), + ) + } + if (peer.sshHostKeys.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_ssh), + value = stringResource(R.string.tailscale_available), + ) + } } } @@ -395,7 +436,7 @@ private fun SectionHeader(title: String) { } @Composable -private fun DetailRow(label: String, value: String) { +private fun DetailRow(label: String, value: String, valueColor: Color? = null) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -410,7 +451,7 @@ private fun DetailRow(label: String, value: String) { Text( text = value, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - color = MaterialTheme.colorScheme.onSurface, + color = valueColor ?: MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.End, ) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt index 1de43e4..4ad59e5 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt @@ -11,17 +11,22 @@ import kotlinx.coroutines.launch data class TailscalePeerData( val id: String, + val stableID: String, val hostName: String, val dnsName: String, val os: String, val tailscaleIPs: List, + val sshHostKeys: List, val online: Boolean, val exitNode: Boolean, val exitNodeOption: Boolean, + val shareeNode: Boolean, + val expired: Boolean, val active: Boolean, val rxBytes: Long, val txBytes: Long, val keyExpiry: Long, + val lastSeen: Long, ) data class TailscaleUserGroupData( @@ -39,8 +44,18 @@ data class TailscaleEndpointData( val networkName: String, val magicDNSSuffix: String, val selfPeer: TailscalePeerData?, + val exitNode: TailscalePeerData?, val userGroups: List, -) +) { + val hasExitNodeCandidates: Boolean + get() { + if (exitNode != null) return true + val selfStableID = selfPeer?.stableID + return userGroups.any { group -> + group.peers.any { it.exitNodeOption && it.stableID != selfStableID } + } + } +} data class TailscaleStatusState( val endpoints: List = emptyList(), @@ -92,6 +107,16 @@ class TailscaleStatusViewModel : BaseViewModel() updateState { copy(endpoints = emptyList(), isSubscribed = false) } } + fun setExitNode(endpointTag: String, stableID: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().setTailscaleExitNode(endpointTag, stableID) + } catch (e: Exception) { + sendErrorMessage(e.message ?: "set exit node failed") + } + } + } + fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag } fun peer(endpointTag: String, peerId: String): TailscalePeerData? { @@ -127,6 +152,7 @@ class TailscaleStatusViewModel : BaseViewModel() userGroups.add(convertUserGroup(groupIterator.next())) } val self = endpoint.getSelf() + val exitNode = endpoint.exitNode return TailscaleEndpointData( endpointTag = endpoint.endpointTag, backendState = endpoint.backendState, @@ -134,6 +160,7 @@ class TailscaleStatusViewModel : BaseViewModel() networkName = endpoint.networkName, magicDNSSuffix = endpoint.magicDNSSuffix, selfPeer = if (self != null) convertPeer(self) else null, + exitNode = if (exitNode != null) convertPeer(exitNode) else null, userGroups = userGroups, ) } @@ -161,20 +188,30 @@ class TailscaleStatusViewModel : BaseViewModel() while (ipIterator.hasNext()) { ips.add(ipIterator.next()) } + val sshKeys = mutableListOf() + val keyIterator = peer.sshHostKeys() + while (keyIterator.hasNext()) { + sshKeys.add(keyIterator.next()) + } val dnsName = peer.getDNSName() return TailscalePeerData( id = if (dnsName.isNotEmpty()) dnsName else peer.hostName, + stableID = peer.stableID, hostName = peer.hostName, dnsName = dnsName, os = peer.getOS(), tailscaleIPs = ips, + sshHostKeys = sshKeys, online = peer.online, exitNode = peer.exitNode, exitNodeOption = peer.exitNodeOption, + shareeNode = peer.shareeNode, + expired = peer.expired, active = peer.active, rxBytes = peer.rxBytes, txBytes = peer.txBytes, keyExpiry = peer.keyExpiry, + lastSeen = peer.lastSeen, ) } } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 7c0215f..15ae744 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -471,6 +471,13 @@ انقضای کلید گره خروجی فعال + به اشتراک گذاشته شده + منقضی شده + انقضا %1$s + انقضای کلید غیرفعال است + آخرین بازدید + در دسترس + بله تست STUN سرور diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 50fc5e4..21cdc47 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -477,6 +477,13 @@ Срок действия ключа Выходной узел Активен + Поделено + Истёк + Истекает %1$s + Срок действия ключа отключён + Был(а) в сети + Доступно + Да STUN-тест Сервер diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a6ad323..3332a1b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -468,6 +468,13 @@ 密钥过期 出口节点 活跃 + 共享至此 + 已过期 + %1$s过期 + 密钥过期已禁用 + 最后在线 + 可用 + STUN 测试 服务器 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f3208e7..f7666e9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -471,6 +471,13 @@ 金鑰到期 出口節點 活躍 + 共享至此 + 已過期 + %1$s過期 + 金鑰到期已停用 + 最後上線 + 可用 + STUN 測試 伺服器 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 70e31fe..1ab3008 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -481,6 +481,14 @@ Stop Direct connection DERP-relayed connection + Shared in + Expired + Expires %1$s + Key expiry disabled + Last Seen + SSH + Available + Yes STUN Test