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.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.Process import android.os.Process
import android.provider.Settings
import android.system.OsConstants import android.system.OsConstants
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@@ -281,19 +282,20 @@ interface PlatformInterfaceWrapper : PlatformInterface {
return RootShellSessionWrapper(rootSession) return RootShellSessionWrapper(rootSession)
} }
override fun readSystemSSHHostKey(): io.nekohasekai.libbox.StringBox { override fun readSystemSSHHostKey(): String {
error("not supported") error("not supported")
} }
override fun lookupSFTPServer(): io.nekohasekai.libbox.StringBox { override fun lookupSFTPServer(): String = runBlocking(Dispatchers.IO) {
val path = runBlocking(Dispatchers.IO) { RootClient.lookupSFTPServer()
RootClient.lookupSFTPServer()
}
val result = io.nekohasekai.libbox.StringBox()
result.value = path
return result
} }
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 { override fun lookupUser(username: String?): io.nekohasekai.libbox.PlatformUser {
val resolved = UserResolver.resolve(Application.packageManager, username!!) val resolved = UserResolver.resolve(Application.packageManager, username!!)
val platformUser = io.nekohasekai.libbox.PlatformUser() 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -23,8 +25,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.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.QrCode2
import androidx.compose.material.icons.filled.Router
import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -98,8 +104,7 @@ fun TailscaleEndpointScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
val hasNetwork = endpoint.networkName.isNotEmpty() val hasThisDevice = endpoint.backendState == "Running" && endpoint.selfPeer != null
val hasMagicDNS = endpoint.magicDNSSuffix.isNotEmpty()
val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates
val hasAuth = endpoint.authURL.isNotEmpty() val hasAuth = endpoint.authURL.isNotEmpty()
@@ -114,7 +119,7 @@ fun TailscaleEndpointScreen(
), ),
) { ) {
Column { Column {
val stateIsLast = !hasNetwork && !hasMagicDNS && !hasExitNode && !hasAuth val stateIsLast = !hasThisDevice && !hasExitNode && !hasAuth
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@@ -122,6 +127,13 @@ fun TailscaleEndpointScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, },
leadingContent = {
Icon(
Icons.Filled.PowerSettingsNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
supportingContent = { supportingContent = {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -149,51 +161,51 @@ fun TailscaleEndpointScreen(
), ),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
if (hasNetwork) { if (endpoint.backendState == "Running" && endpoint.selfPeer != null) {
val networkIsLast = !hasMagicDNS && !hasExitNode && !hasAuth val thisDeviceIsLast = !hasExitNode && !hasAuth
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
stringResource(R.string.tailscale_network), stringResource(R.string.tailscale_this_device),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, },
leadingContent = {
Icon(
Icons.Filled.Computer,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
supportingContent = { supportingContent = {
Text( Text(
endpoint.networkName, endpoint.selfPeer.displayName,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
) )
}, },
modifier = if (networkIsLast) { trailingContent = {
Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) Icon(
} else { imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
Modifier contentDescription = null,
}, modifier = Modifier.size(20.dp),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hasMagicDNS) {
val magicDNSIsLast = !hasExitNode && !hasAuth
ListItem(
headlineContent = {
Text(
stringResource(R.string.tailscale_magic_dns),
style = MaterialTheme.typography.bodyLarge,
) )
}, },
supportingContent = { modifier = Modifier
Text( .clip(
endpoint.magicDNSSuffix, if (thisDeviceIsLast) {
style = MaterialTheme.typography.bodyMedium, RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)
color = MaterialTheme.colorScheme.onSurfaceVariant, } else {
RoundedCornerShape(0.dp)
},
) )
}, .clickable {
modifier = if (magicDNSIsLast) { navController.navigate(
Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}",
} else { )
Modifier },
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
@@ -206,14 +218,29 @@ fun TailscaleEndpointScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
}, },
leadingContent = {
Icon(
Icons.Filled.Router,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
supportingContent = { supportingContent = {
Text( Text(
endpoint.exitNode?.hostName ?: stringResource(R.string.disabled), endpoint.exitNode?.displayName ?: stringResource(R.string.disabled),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
) )
}, },
trailingContent = {
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier modifier = Modifier
.clip( .clip(
if (exitNodeIsLast) { 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 // User group sections
for (group in endpoint.userGroups) { for (group in endpoint.userGroups) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -388,7 +391,7 @@ private fun SectionHeader(title: String) {
) )
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun PeerItem( private fun PeerItem(
peer: TailscalePeerData, peer: TailscalePeerData,
@@ -407,20 +410,25 @@ private fun PeerItem(
) )
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.Top,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier.height(24.dp),
.size(8.dp) contentAlignment = Alignment.Center,
.clip(CircleShape) ) {
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), Box(
) modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (peer.online) Color(0xFF4CAF50) else Color.Gray),
)
}
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Text(
peer.hostName, peer.displayName,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
if (firstIP != null) { if (firstIP != null) {
@@ -431,13 +439,27 @@ private fun PeerItem(
) )
} }
if (badges.isNotEmpty()) { if (badges.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (badge in badges) { for (badge in badges) {
PeerBadgeView(badge) 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 @Composable
private fun peerBadges(peer: TailscalePeerData): List<PeerBadge> { private fun peerBadges(peer: TailscalePeerData): List<PeerBadge> {
if (!peer.online) return emptyList()
val badges = mutableListOf<PeerBadge>() val badges = mutableListOf<PeerBadge>()
if (peer.shareeNode) { if (peer.shareeNode) {
badges += PeerBadge(stringResource(R.string.tailscale_shared_in), Color(0xFFF44336)) badges += PeerBadge(stringResource(R.string.tailscale_shared_in), Color(0xFFF44336))
@@ -486,6 +509,7 @@ private fun PeerBadgeView(badge: PeerBadge) {
text = badge.text, text = badge.text,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = Color.White, color = Color.White,
maxLines = 1,
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(50)) .clip(RoundedCornerShape(50))
.background(badge.color) .background(badge.color)
@@ -80,7 +80,7 @@ fun TailscaleExitNodePickerScreen(
val candidates = endpoint.userGroups val candidates = endpoint.userGroups
.flatMap { it.peers } .flatMap { it.peers }
.filter { it.exitNodeOption && it.stableID != selfStableID } .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()) { Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField( OutlinedTextField(
@@ -174,7 +174,7 @@ private fun PeerRow(
) )
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = peer.hostName, text = peer.displayName,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
) )
val firstIP = peer.tailscaleIPs.firstOrNull() 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward 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.Check
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
@@ -75,7 +76,8 @@ fun TailscalePeerScreen(
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val peer = viewModel.peer(endpointTag, peerId) 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 pingViewModel: TailscalePingViewModel = viewModel()
val pingState by pingViewModel.uiState.collectAsState() val pingState by pingViewModel.uiState.collectAsState()
@@ -92,7 +94,7 @@ fun TailscalePeerScreen(
title = { title = {
Column { Column {
Text( Text(
peer?.hostName ?: "", peer?.displayName ?: "",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Row( Row(
@@ -143,6 +145,60 @@ fun TailscalePeerScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .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 // Tailscale Addresses section
SectionHeader(stringResource(R.string.tailscale_addresses)) SectionHeader(stringResource(R.string.tailscale_addresses))
Card( Card(
@@ -165,6 +221,14 @@ fun TailscalePeerScreen(
onCopy = { copiedAddress = it }, 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) { for (ip in peer.tailscaleIPs) {
AddressRow( AddressRow(
address = ip, address = ip,
@@ -27,7 +27,9 @@ data class TailscalePeerData(
val txBytes: Long, val txBytes: Long,
val keyExpiry: Long, val keyExpiry: Long,
val lastSeen: Long, val lastSeen: Long,
) ) {
val displayName: String get() = dnsName.substringBefore(".").ifEmpty { hostName }
}
data class TailscaleUserGroupData( data class TailscaleUserGroupData(
val id: Long, val id: Long,
@@ -46,6 +48,7 @@ data class TailscaleEndpointData(
val selfPeer: TailscalePeerData?, val selfPeer: TailscalePeerData?,
val exitNode: TailscalePeerData?, val exitNode: TailscalePeerData?,
val userGroups: List<TailscaleUserGroupData>, val userGroups: List<TailscaleUserGroupData>,
val keyAuth: Boolean,
) { ) {
val hasExitNodeCandidates: Boolean val hasExitNodeCandidates: Boolean
get() { 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 endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag }
fun peer(endpointTag: String, peerId: String): TailscalePeerData? { fun peer(endpointTag: String, peerId: String): TailscalePeerData? {
@@ -166,6 +179,7 @@ class TailscaleStatusViewModel : BaseViewModel<TailscaleStatusState, Nothing>()
selfPeer = if (self != null) convertPeer(self) else null, selfPeer = if (self != null) convertPeer(self) else null,
exitNode = if (exitNode != null) convertPeer(exitNode) else null, exitNode = if (exitNode != null) convertPeer(exitNode) else null,
userGroups = userGroups, userGroups = userGroups,
keyAuth = endpoint.keyAuth,
) )
} }
+1
View File
@@ -461,6 +461,7 @@
<string name="tailscale_status">وضعیت</string> <string name="tailscale_status">وضعیت</string>
<string name="tailscale_state">وضعیت</string> <string name="tailscale_state">وضعیت</string>
<string name="tailscale_network">شبکه</string> <string name="tailscale_network">شبکه</string>
<string name="tailscale_hostname">نام میزبان</string>
<string name="tailscale_open_auth_url">باز کردن لینک احراز هویت</string> <string name="tailscale_open_auth_url">باز کردن لینک احراز هویت</string>
<string name="tailscale_open_auth_url_qr_code">نمایش QR کد لینک احراز هویت</string> <string name="tailscale_open_auth_url_qr_code">نمایش QR کد لینک احراز هویت</string>
<string name="tailscale_this_device">این دستگاه</string> <string name="tailscale_this_device">این دستگاه</string>
@@ -467,6 +467,7 @@
<string name="tailscale_status">Статус</string> <string name="tailscale_status">Статус</string>
<string name="tailscale_state">Состояние</string> <string name="tailscale_state">Состояние</string>
<string name="tailscale_network">Сеть</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">Открыть URL авторизации</string>
<string name="tailscale_open_auth_url_qr_code">Показать QR-код авторизации</string> <string name="tailscale_open_auth_url_qr_code">Показать QR-код авторизации</string>
<string name="tailscale_this_device">Это устройство</string> <string name="tailscale_this_device">Это устройство</string>
@@ -458,6 +458,7 @@
<string name="tailscale_status">状态</string> <string name="tailscale_status">状态</string>
<string name="tailscale_state">状态</string> <string name="tailscale_state">状态</string>
<string name="tailscale_network">网络</string> <string name="tailscale_network">网络</string>
<string name="tailscale_hostname">主机名</string>
<string name="tailscale_open_auth_url">打开认证链接</string> <string name="tailscale_open_auth_url">打开认证链接</string>
<string name="tailscale_open_auth_url_qr_code">显示认证链接二维码</string> <string name="tailscale_open_auth_url_qr_code">显示认证链接二维码</string>
<string name="tailscale_this_device">此设备</string> <string name="tailscale_this_device">此设备</string>
@@ -461,6 +461,7 @@
<string name="tailscale_status">狀態</string> <string name="tailscale_status">狀態</string>
<string name="tailscale_state">狀態</string> <string name="tailscale_state">狀態</string>
<string name="tailscale_network">網路</string> <string name="tailscale_network">網路</string>
<string name="tailscale_hostname">主機名</string>
<string name="tailscale_open_auth_url">開啟認證連結</string> <string name="tailscale_open_auth_url">開啟認證連結</string>
<string name="tailscale_open_auth_url_qr_code">顯示認證連結 QR 碼</string> <string name="tailscale_open_auth_url_qr_code">顯示認證連結 QR 碼</string>
<string name="tailscale_this_device">此裝置</string> <string name="tailscale_this_device">此裝置</string>
+2
View File
@@ -463,9 +463,11 @@
<string name="tailscale_state">State</string> <string name="tailscale_state">State</string>
<string name="tailscale_network">Network</string> <string name="tailscale_network">Network</string>
<string name="tailscale_magic_dns" translatable="false">MagicDNS</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">Open Auth URL</string>
<string name="tailscale_open_auth_url_qr_code">Show Auth URL QR Code</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_this_device">This Device</string>
<string name="tailscale_logout">Log out</string>
<string name="tailscale_connected">Connected</string> <string name="tailscale_connected">Connected</string>
<string name="tailscale_not_connected">Not Connected</string> <string name="tailscale_not_connected">Not Connected</string>
<string name="tailscale_addresses">Tailscale Addresses</string> <string name="tailscale_addresses">Tailscale Addresses</string>