From 24ac124bae1b2230d12be0338154300efd581e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 2 Jun 2026 18:19:31 +0800 Subject: [PATCH] tailscale: Add device info and logout --- .../sfa/bg/PlatformInterfaceWrapper.kt | 18 +- .../screen/tools/TailscaleEndpointScreen.kt | 158 ++++++++++-------- .../tools/TailscaleExitNodePickerScreen.kt | 4 +- .../screen/tools/TailscalePeerScreen.kt | 68 +++++++- .../screen/tools/TailscaleStatusViewModel.kt | 16 +- app/src/main/res/values-fa/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + 10 files changed, 190 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index 07c0776..1e2afa0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -5,6 +5,7 @@ import android.net.NetworkCapabilities import android.os.Build import android.os.ParcelFileDescriptor import android.os.Process +import android.provider.Settings import android.system.OsConstants import android.util.Log import androidx.annotation.RequiresApi @@ -281,19 +282,20 @@ interface PlatformInterfaceWrapper : PlatformInterface { return RootShellSessionWrapper(rootSession) } - override fun readSystemSSHHostKey(): io.nekohasekai.libbox.StringBox { + override fun readSystemSSHHostKey(): String { error("not supported") } - override fun lookupSFTPServer(): io.nekohasekai.libbox.StringBox { - val path = runBlocking(Dispatchers.IO) { - RootClient.lookupSFTPServer() - } - val result = io.nekohasekai.libbox.StringBox() - result.value = path - return result + override fun lookupSFTPServer(): String = runBlocking(Dispatchers.IO) { + RootClient.lookupSFTPServer() } + override fun tailscaleHostname(): String = Settings.Global.getString( + Application.application.contentResolver, + Settings.Global.DEVICE_NAME, + )?.takeIf { it.isNotBlank() } + ?: "${Build.MANUFACTURER} ${Build.MODEL}" + override fun lookupUser(username: String?): io.nekohasekai.libbox.PlatformUser { val resolved = UserResolver.resolve(Application.packageManager, username!!) val platformUser = io.nekohasekai.libbox.PlatformUser() 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 b977fb0..bb34a12 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 @@ -10,6 +10,8 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -23,8 +25,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.Router import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -98,8 +104,7 @@ fun TailscaleEndpointScreen( .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { - val hasNetwork = endpoint.networkName.isNotEmpty() - val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty() + val hasThisDevice = endpoint.backendState == "Running" && endpoint.selfPeer != null val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates val hasAuth = endpoint.authURL.isNotEmpty() @@ -114,7 +119,7 @@ fun TailscaleEndpointScreen( ), ) { Column { - val stateIsLast = !hasNetwork && !hasMagicDNS && !hasExitNode && !hasAuth + val stateIsLast = !hasThisDevice && !hasExitNode && !hasAuth ListItem( headlineContent = { Text( @@ -122,6 +127,13 @@ fun TailscaleEndpointScreen( style = MaterialTheme.typography.bodyLarge, ) }, + leadingContent = { + Icon( + Icons.Filled.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, supportingContent = { Row( verticalAlignment = Alignment.CenterVertically, @@ -149,51 +161,51 @@ fun TailscaleEndpointScreen( ), colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) - if (hasNetwork) { - val networkIsLast = !hasMagicDNS && !hasExitNode && !hasAuth + if (endpoint.backendState == "Running" && endpoint.selfPeer != null) { + val thisDeviceIsLast = !hasExitNode && !hasAuth ListItem( headlineContent = { Text( - stringResource(R.string.tailscale_network), + stringResource(R.string.tailscale_this_device), style = MaterialTheme.typography.bodyLarge, ) }, + leadingContent = { + Icon( + Icons.Filled.Computer, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, supportingContent = { Text( - endpoint.networkName, + endpoint.selfPeer.displayName, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, ) }, - modifier = if (networkIsLast) { - Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - } else { - Modifier - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - ) - } - if (hasMagicDNS) { - val magicDNSIsLast = !hasExitNode && !hasAuth - ListItem( - headlineContent = { - Text( - stringResource(R.string.tailscale_magic_dns), - style = MaterialTheme.typography.bodyLarge, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, - supportingContent = { - Text( - endpoint.magicDNSSuffix, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .clip( + if (thisDeviceIsLast) { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + } else { + RoundedCornerShape(0.dp) + }, ) - }, - modifier = if (magicDNSIsLast) { - Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - } else { - Modifier - }, + .clickable { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}", + ) + }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } @@ -206,14 +218,29 @@ fun TailscaleEndpointScreen( style = MaterialTheme.typography.bodyLarge, ) }, + leadingContent = { + Icon( + Icons.Filled.Router, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, supportingContent = { Text( - endpoint.exitNode?.hostName ?: stringResource(R.string.disabled), + endpoint.exitNode?.displayName ?: stringResource(R.string.disabled), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, ) }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, modifier = Modifier .clip( if (exitNodeIsLast) { @@ -273,30 +300,6 @@ fun TailscaleEndpointScreen( } } - // This Device section - if (endpoint.backendState == "Running" && endpoint.selfPeer != null) { - Spacer(modifier = Modifier.height(16.dp)) - SectionHeader(stringResource(R.string.tailscale_this_device)) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - PeerItem( - peer = endpoint.selfPeer, - onClick = { - navController.navigate( - "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}", - ) - }, - modifier = Modifier.clip(RoundedCornerShape(12.dp)), - ) - } - } - // User group sections for (group in endpoint.userGroups) { Spacer(modifier = Modifier.height(16.dp)) @@ -388,7 +391,7 @@ private fun SectionHeader(title: String) { ) } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable private fun PeerItem( peer: TailscalePeerData, @@ -407,20 +410,25 @@ private fun PeerItem( ) .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, ) { Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), - ) + modifier = Modifier.height(24.dp), + contentAlignment = Alignment.Center, + ) { + 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, + peer.displayName, style = MaterialTheme.typography.bodyLarge, ) if (firstIP != null) { @@ -431,13 +439,27 @@ private fun PeerItem( ) } if (badges.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { for (badge in badges) { PeerBadgeView(badge) } } } } + Box( + modifier = Modifier.height(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } @@ -445,6 +467,7 @@ private data class PeerBadge(val text: String, val color: Color) @Composable private fun peerBadges(peer: TailscalePeerData): List { + if (!peer.online) return emptyList() val badges = mutableListOf() if (peer.shareeNode) { badges += PeerBadge(stringResource(R.string.tailscale_shared_in), Color(0xFFF44336)) @@ -486,6 +509,7 @@ private fun PeerBadgeView(badge: PeerBadge) { text = badge.text, style = MaterialTheme.typography.labelSmall, color = Color.White, + maxLines = 1, modifier = Modifier .clip(RoundedCornerShape(50)) .background(badge.color) 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 index ca5107c..da4219a 100644 --- 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 @@ -80,7 +80,7 @@ fun TailscaleExitNodePickerScreen( val candidates = endpoint.userGroups .flatMap { it.peers } .filter { it.exitNodeOption && it.stableID != selfStableID } - .filter { searchText.isEmpty() || it.hostName.contains(searchText, ignoreCase = true) } + .filter { searchText.isEmpty() || it.displayName.contains(searchText, ignoreCase = true) || it.hostName.contains(searchText, ignoreCase = true) } Column(modifier = Modifier.fillMaxSize()) { OutlinedTextField( @@ -174,7 +174,7 @@ private fun PeerRow( ) Column(modifier = Modifier.weight(1f)) { Text( - text = peer.hostName, + text = peer.displayName, style = MaterialTheme.typography.bodyLarge, ) val firstIP = peer.tailscaleIPs.firstOrNull() 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 839d714..91c1e45 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 @@ -22,6 +22,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop @@ -75,7 +76,8 @@ fun TailscalePeerScreen( ) { val state by viewModel.uiState.collectAsState() val peer = viewModel.peer(endpointTag, peerId) - val isSelf = viewModel.endpoint(endpointTag)?.selfPeer?.id == peerId + val endpoint = viewModel.endpoint(endpointTag) + val isSelf = endpoint?.selfPeer?.id == peerId val pingViewModel: TailscalePingViewModel = viewModel() val pingState by pingViewModel.uiState.collectAsState() @@ -92,7 +94,7 @@ fun TailscalePeerScreen( title = { Column { Text( - peer?.hostName ?: "", + peer?.displayName ?: "", style = MaterialTheme.typography.titleMedium, ) Row( @@ -143,6 +145,60 @@ fun TailscalePeerScreen( .verticalScroll(rememberScrollState()) .padding(vertical = 8.dp), ) { + // Network section (self peer only): network name + logout + val networkName = endpoint?.networkName + val showLogout = isSelf && endpoint?.backendState == "Running" && endpoint?.keyAuth == false + if (isSelf && (!networkName.isNullOrEmpty() || showLogout)) { + SectionHeader(stringResource(R.string.tailscale_network)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column { + if (!networkName.isNullOrEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AddressRow( + address = networkName, + label = stringResource(R.string.tailscale_network), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + } + if (showLogout) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_logout), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.clickable { + viewModel.logout(endpointTag) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + // Tailscale Addresses section SectionHeader(stringResource(R.string.tailscale_addresses)) Card( @@ -165,6 +221,14 @@ fun TailscalePeerScreen( onCopy = { copiedAddress = it }, ) } + if (peer.hostName.isNotEmpty()) { + AddressRow( + address = peer.hostName, + label = stringResource(R.string.tailscale_hostname), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } for (ip in peer.tailscaleIPs) { AddressRow( address = ip, 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 5ceb068..1ba7709 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 @@ -27,7 +27,9 @@ data class TailscalePeerData( val txBytes: Long, val keyExpiry: Long, val lastSeen: Long, -) +) { + val displayName: String get() = dnsName.substringBefore(".").ifEmpty { hostName } +} data class TailscaleUserGroupData( val id: Long, @@ -46,6 +48,7 @@ data class TailscaleEndpointData( val selfPeer: TailscalePeerData?, val exitNode: TailscalePeerData?, val userGroups: List, + val keyAuth: Boolean, ) { val hasExitNodeCandidates: Boolean get() { @@ -121,6 +124,16 @@ class TailscaleStatusViewModel : BaseViewModel() } } + fun logout(endpointTag: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().tailscaleLogout(endpointTag) + } catch (e: Exception) { + sendErrorMessage(e.message ?: "logout failed") + } + } + } + fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag } fun peer(endpointTag: String, peerId: String): TailscalePeerData? { @@ -166,6 +179,7 @@ class TailscaleStatusViewModel : BaseViewModel() selfPeer = if (self != null) convertPeer(self) else null, exitNode = if (exitNode != null) convertPeer(exitNode) else null, userGroups = userGroups, + keyAuth = endpoint.keyAuth, ) } diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 2049e4a..dc8f310 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -461,6 +461,7 @@ وضعیت وضعیت شبکه + نام میزبان باز کردن لینک احراز هویت نمایش QR کد لینک احراز هویت این دستگاه diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 8b45871..df62c71 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -467,6 +467,7 @@ Статус Состояние Сеть + Имя хоста Открыть URL авторизации Показать QR-код авторизации Это устройство diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 99da2f9..b943554 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -458,6 +458,7 @@ 状态 状态 网络 + 主机名 打开认证链接 显示认证链接二维码 此设备 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 65fe1f8..849e498 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -461,6 +461,7 @@ 狀態 狀態 網路 + 主機名 開啟認證連結 顯示認證連結 QR 碼 此裝置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d96338b..5234f0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -463,9 +463,11 @@ State Network MagicDNS + Hostname Open Auth URL Show Auth URL QR Code This Device + Log out Connected Not Connected Tailscale Addresses