Files
sing-box-for-apple/ApplicationLibrary/Views/Tools/TailscalePeerView.swift
T
2026-04-23 08:12:53 +08:00

225 lines
7.5 KiB
Swift

import Libbox
import Library
import SwiftUI
#if os(iOS) || os(tvOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
public struct TailscalePeerView: View {
let peer: TailscalePeerData
let endpointTag: String
let isSelf: Bool
@State private var copiedAddress: String?
@StateObject private var pingViewModel = TailscalePingViewModel()
public init(peer: TailscalePeerData, endpointTag: String, isSelf: Bool) {
self.peer = peer
self.endpointTag = endpointTag
self.isSelf = isSelf
}
public var body: some View {
FormView {
Section("Tailscale Addresses") {
if !peer.dnsName.isEmpty {
addressRow(LibboxFormatFQDN(peer.dnsName), label: "MagicDNS")
}
ForEach(Array(peer.tailscaleIPs.enumerated()), id: \.offset) { _, ip in
if ip.contains(":") {
addressRow(ip, label: "IPv6")
} else {
addressRow(ip, label: "IPv4")
}
}
}
if !isSelf, peer.online, let peerIP = peer.tailscaleIPs.first {
Section {
if pingViewModel.hasResult {
connectionTypeRow
}
if pingViewModel.isRunning, pingViewModel.hasResult {
pingChartView
}
if !pingViewModel.hasResult {
Text("No data")
.foregroundStyle(.secondary)
}
} header: {
HStack {
Text("Ping")
Spacer()
ActionIconButton(pingViewModel.isRunning ? "stop.fill" : "play.fill") {
if pingViewModel.isRunning {
pingViewModel.stop()
} else {
pingViewModel.start(endpointTag: endpointTag, peerIP: peerIP)
}
}
.textCase(nil)
}
}
}
if peer.keyExpiry > 0 || !peer.os.isEmpty || peer.exitNode {
Section("Details") {
if peer.keyExpiry > 0 {
FormTextItem("Key Expiry", "key") {
Text(keyExpiryText)
}
}
if !peer.os.isEmpty {
FormTextItem("OS", "desktopcomputer") {
Text(peer.os)
}
}
if peer.exitNode {
FormTextItem("Exit Node", "arrow.triangle.turn.up.right.diamond") {
Text("Active")
}
}
}
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(peer.hostName)
.font(.headline)
HStack(spacing: 4) {
Image(systemName: "circle.fill")
.font(.system(size: 6))
.foregroundStyle(peer.online ? .green : Color(.systemGray))
Text(peer.online ? "Connected" : "Not Connected")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.onDisappear {
if pingViewModel.isRunning {
pingViewModel.stop()
}
}
}
private var connectionTypeRow: some View {
HStack(spacing: 8) {
if pingViewModel.isDirect {
Image(systemName: "arrow.right")
.foregroundStyle(.green)
Text("Direct connection")
.foregroundStyle(.green)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(.orange)
Text("DERP-relayed connection")
.foregroundStyle(.orange)
}
Spacer()
Text(verbatim: "\(Int(pingViewModel.latencyMs)) ms")
.font(.headline)
}
}
private var pingChartView: some View {
#if os(tvOS)
let chartHeight: CGFloat = 160
let labelWidth: CGFloat = 80
#else
let chartHeight: CGFloat = 80
let labelWidth: CGFloat = 50
#endif
return HStack(alignment: .center) {
TrafficLineChart(
data: pingViewModel.latencyHistory,
lineColor: pingViewModel.isDirect ? .green : .blue,
chartHeight: chartHeight
)
VStack(alignment: .trailing, spacing: 0) {
let maxMs = max(Int((pingViewModel.latencyHistory.max() ?? 1) * 1.2), 1)
Text(verbatim: "\(maxMs)ms")
Spacer()
Text(verbatim: "\(maxMs * 2 / 3)ms")
Spacer()
Text(verbatim: "\(maxMs / 3)ms")
Spacer()
Text(verbatim: "0ms")
}
.font(.caption2)
.foregroundStyle(.secondary)
.frame(width: labelWidth)
}
.frame(height: chartHeight)
#if os(tvOS)
.padding(.vertical, 8)
#endif
}
private var keyExpiryText: String {
let date = Date(timeIntervalSince1970: TimeInterval(peer.keyExpiry))
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
return formatter.localizedString(for: date, relativeTo: Date())
}
private func addressRow(_ address: String, label: String) -> some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(address)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
#if !os(tvOS)
Button {
copyToClipboard(address)
} label: {
if copiedAddress == address {
Image(systemName: "checkmark")
.foregroundStyle(.secondary)
} else {
Image(systemName: "doc.on.doc")
.foregroundStyle(.blue)
}
}
#if os(macOS)
.buttonStyle(.plain)
#endif
#endif
}
}
private func copyToClipboard(_ text: String) {
#if os(iOS)
UIPasteboard.general.string = text
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
#elseif os(macOS)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
#endif
withAnimation {
copiedAddress = text
}
Task {
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
withAnimation {
if copiedAddress == text {
copiedAddress = nil
}
}
}
}
}