tailscale: Add device info and logout

This commit is contained in:
世界
2026-07-06 21:15:25 +08:00
parent 33514e54d9
commit 24ac124bae
10 changed files with 190 additions and 80 deletions
@@ -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()
@@ -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<PeerBadge> {
if (!peer.online) return emptyList()
val badges = mutableListOf<PeerBadge>()
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)
@@ -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()
@@ -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,
@@ -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<TailscaleUserGroupData>,
val keyAuth: Boolean,
) {
val hasExitNodeCandidates: Boolean
get() {
@@ -121,6 +124,16 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
}
}
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<TailscaleStatusState, Nothing>()
selfPeer = if (self != null) convertPeer(self) else null,
exitNode = if (exitNode != null) convertPeer(exitNode) else null,
userGroups = userGroups,
keyAuth = endpoint.keyAuth,
)
}
+1
View File
@@ -461,6 +461,7 @@
<string name="tailscale_status">وضعیت</string>
<string name="tailscale_state">وضعیت</string>
<string name="tailscale_network">شبکه</string>
<string name="tailscale_hostname">نام میزبان</string>
<string name="tailscale_open_auth_url">باز کردن لینک احراز هویت</string>
<string name="tailscale_open_auth_url_qr_code">نمایش QR کد لینک احراز هویت</string>
<string name="tailscale_this_device">این دستگاه</string>
@@ -467,6 +467,7 @@
<string name="tailscale_status">Статус</string>
<string name="tailscale_state">Состояние</string>
<string name="tailscale_network">Сеть</string>
<string name="tailscale_hostname">Имя хоста</string>
<string name="tailscale_open_auth_url">Открыть URL авторизации</string>
<string name="tailscale_open_auth_url_qr_code">Показать QR-код авторизации</string>
<string name="tailscale_this_device">Это устройство</string>
@@ -458,6 +458,7 @@
<string name="tailscale_status">状态</string>
<string name="tailscale_state">状态</string>
<string name="tailscale_network">网络</string>
<string name="tailscale_hostname">主机名</string>
<string name="tailscale_open_auth_url">打开认证链接</string>
<string name="tailscale_open_auth_url_qr_code">显示认证链接二维码</string>
<string name="tailscale_this_device">此设备</string>
@@ -461,6 +461,7 @@
<string name="tailscale_status">狀態</string>
<string name="tailscale_state">狀態</string>
<string name="tailscale_network">網路</string>
<string name="tailscale_hostname">主機名</string>
<string name="tailscale_open_auth_url">開啟認證連結</string>
<string name="tailscale_open_auth_url_qr_code">顯示認證連結 QR 碼</string>
<string name="tailscale_this_device">此裝置</string>
+2
View File
@@ -463,9 +463,11 @@
<string name="tailscale_state">State</string>
<string name="tailscale_network">Network</string>
<string name="tailscale_magic_dns" translatable="false">MagicDNS</string>
<string name="tailscale_hostname">Hostname</string>
<string name="tailscale_open_auth_url">Open Auth URL</string>
<string name="tailscale_open_auth_url_qr_code">Show Auth URL QR Code</string>
<string name="tailscale_this_device">This Device</string>
<string name="tailscale_logout">Log out</string>
<string name="tailscale_connected">Connected</string>
<string name="tailscale_not_connected">Not Connected</string>
<string name="tailscale_addresses">Tailscale Addresses</string>