225 lines
7.5 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|