tools: Tailscale status

This commit is contained in:
世界
2026-04-23 08:12:53 +08:00
parent 22e1941e5e
commit d833ab870f
7 changed files with 1172 additions and 1 deletions
@@ -146,6 +146,33 @@ public extension View {
}
#endif
public struct ActionIconButton: View {
let systemImage: String
let action: () -> Void
public init(_ systemImage: String, action: @escaping () -> Void) {
self.systemImage = systemImage
self.action = action
}
public var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 12))
#if !os(tvOS)
.frame(width: 44, height: 32)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 8))
#endif
.contentShape(Rectangle())
}
.buttonStyle(.plain)
#if os(tvOS)
.actionButtonStyle()
#endif
}
}
public extension View {
func cardStyle() -> some View {
modifier(CardStyleModifier())
@@ -0,0 +1,116 @@
import Library
import SwiftUI
@MainActor
public struct TailscaleEndpointView: View {
@ObservedObject var viewModel: TailscaleStatusViewModel
@Environment(\.dismiss) private var dismiss
@State private var showAuthURLQRCode = false
let endpointTag: String
public init(viewModel: TailscaleStatusViewModel, endpointTag: String) {
self.viewModel = viewModel
self.endpointTag = endpointTag
}
private var endpoint: TailscaleEndpointData? {
viewModel.endpoint(tag: endpointTag)
}
public var body: some View {
FormView {
if let endpoint {
Section("Status") {
FormTextItem("State", "power") {
HStack(spacing: 6) {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(stateColor(endpoint.backendState))
Text(endpoint.backendState)
}
}
if !endpoint.networkName.isEmpty {
FormTextItem("Network", "network") {
Text(endpoint.networkName)
}
}
if !endpoint.magicDNSSuffix.isEmpty {
FormTextItem("MagicDNS", "globe") {
Text(endpoint.magicDNSSuffix)
}
}
if !endpoint.authURL.isEmpty {
if let url = URL(string: endpoint.authURL) {
#if !os(tvOS)
Link(destination: url) {
Label("Open Auth URL", systemImage: "arrow.up.forward.app")
}
#endif
Button {
showAuthURLQRCode = true
} label: {
Label("Open Auth URL as QR Code", systemImage: "qrcode")
}
}
}
}
if endpoint.backendState == "Running", let selfPeer = endpoint.selfPeer {
Section("This Device") {
peerLink(selfPeer, isSelf: true)
}
}
ForEach(endpoint.userGroups) { group in
Section {
ForEach(group.peers) { peer in
peerLink(peer, isSelf: false)
}
} header: {
Text(group.displayName.isEmpty ? group.loginName : group.displayName)
}
}
}
}
.navigationTitle(endpointTag)
.sheet(isPresented: $showAuthURLQRCode) {
if let endpoint {
URLQRCodeSheet(url: endpoint.authURL, title: String(localized: "Auth URL"))
}
}
.onChangeCompat(of: endpoint == nil) { isNil in
if isNil {
dismiss()
}
}
}
private func peerLink(_ peer: TailscalePeerData, isSelf: Bool) -> some View {
FormNavigationLink {
TailscalePeerView(peer: peer, endpointTag: endpointTag, isSelf: isSelf)
} label: {
HStack {
Image(systemName: "circle.fill")
.font(.system(size: 8))
.foregroundStyle(peer.online ? .green : Color(.systemGray))
VStack(alignment: .leading, spacing: 2) {
Text(peer.hostName)
if let firstIP = peer.tailscaleIPs.first {
Text(firstIP)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
private func stateColor(_ state: String) -> Color {
switch state {
case "Running": .green
case "NeedsLogin", "NeedsMachineAuth": .orange
case "Starting": .yellow
default: Color(.systemGray)
}
}
}
@@ -0,0 +1,224 @@
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
}
}
}
}
}
@@ -0,0 +1,89 @@
import Foundation
import Libbox
import Library
import SwiftUI
@MainActor
public final class TailscalePingViewModel: BaseViewModel {
@Published public var isRunning = false
@Published public var latencyMs: Double = 0
@Published public var isDirect: Bool = false
@Published public var derpRegionCode: String = ""
@Published public var endpoint: String = ""
@Published public var hasResult = false
@Published public var latencyHistory: [CGFloat] = []
private let maxHistorySize = 30
private var commandClient: LibboxCommandClient?
private var runningTask: Task<Void, Never>?
public func start(endpointTag: String, peerIP: String) {
latencyHistory = []
hasResult = false
isRunning = true
let client = LibboxNewStandaloneCommandClient()!
commandClient = client
let handler = PingHandler(self)
runningTask = Task { [weak self] in
await Task.detached {
try? client.startTailscalePing(endpointTag, peerIP: peerIP, handler: handler)
}.value
self?.runningTask = nil
}
}
public func stop() {
runningTask?.cancel()
runningTask = nil
try? commandClient?.disconnect()
commandClient = nil
isRunning = false
}
fileprivate func appendLatency(_ ms: Double) {
latencyHistory.append(CGFloat(ms))
if latencyHistory.count > maxHistorySize {
latencyHistory.removeFirst()
}
}
private final class PingHandler: NSObject, LibboxTailscalePingHandlerProtocol, @unchecked Sendable {
private weak var viewModel: TailscalePingViewModel?
init(_ viewModel: TailscalePingViewModel?) {
self.viewModel = viewModel
}
func onPingResult(_ result: LibboxTailscalePingResult?) {
guard let result else { return }
let latencyMs = result.latencyMs
let isDirect = result.isDirect
let derpRegionCode = result.derpRegionCode
let endpoint = result.endpoint
let error = result.error
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
if !error.isEmpty {
return
}
viewModel.latencyMs = latencyMs
viewModel.isDirect = isDirect
viewModel.derpRegionCode = derpRegionCode
viewModel.endpoint = endpoint
viewModel.hasResult = true
viewModel.appendLatency(latencyMs)
}
}
func onError(_: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isRunning else { return }
viewModel.isRunning = false
viewModel.commandClient = nil
viewModel.runningTask = nil
}
}
}
}
@@ -0,0 +1,178 @@
import Foundation
import Libbox
import Library
import SwiftUI
public struct TailscalePeerData: Identifiable {
public let id: String
public let hostName: String
public let dnsName: String
public let os: String
public let tailscaleIPs: [String]
public let online: Bool
public let exitNode: Bool
public let exitNodeOption: Bool
public let active: Bool
public let rxBytes: Int64
public let txBytes: Int64
public let keyExpiry: Int64
}
public struct TailscaleUserGroupData: Identifiable {
public let id: Int64
public let loginName: String
public let displayName: String
public let profilePicURL: String
public let peers: [TailscalePeerData]
}
public struct TailscaleEndpointData: Identifiable {
public let id: String
public let endpointTag: String
public let backendState: String
public let authURL: String
public let networkName: String
public let magicDNSSuffix: String
public let selfPeer: TailscalePeerData?
public let userGroups: [TailscaleUserGroupData]
}
@MainActor
public final class TailscaleStatusViewModel: BaseViewModel {
@Published public var endpoints: [TailscaleEndpointData] = []
@Published public var isSubscribed = false
private var runningTask: Task<Void, Never>?
public func subscribe() {
guard !isSubscribed else { return }
isSubscribed = true
let handler = StatusHandler(self)
runningTask = Task { [weak self] in
do {
try await Task.detached {
try LibboxNewStandaloneCommandClient()!.subscribeTailscaleStatus(handler)
}.value
} catch {
guard let self else { return }
self.isSubscribed = false
self.endpoints = []
}
self?.runningTask = nil
}
}
public func cancel() {
runningTask?.cancel()
runningTask = nil
isSubscribed = false
endpoints = []
}
public func endpoint(tag: String) -> TailscaleEndpointData? {
endpoints.first { $0.endpointTag == tag }
}
private final class StatusHandler: NSObject, LibboxTailscaleStatusHandlerProtocol, @unchecked Sendable {
private weak var viewModel: TailscaleStatusViewModel?
init(_ viewModel: TailscaleStatusViewModel?) {
self.viewModel = viewModel
}
func onStatusUpdate(_ status: LibboxTailscaleStatusUpdate?) {
guard let status else { return }
let endpoints = Self.convertUpdate(status)
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isSubscribed else { return }
viewModel.endpoints = endpoints
}
}
func onError(_ message: String?) {
DispatchQueue.main.async { [self] in
guard let viewModel, viewModel.isSubscribed else { return }
viewModel.isSubscribed = false
viewModel.endpoints = []
if let message {
viewModel.alert = AlertState(errorMessage: message)
}
}
}
private static func convertUpdate(_ status: LibboxTailscaleStatusUpdate) -> [TailscaleEndpointData] {
var endpoints: [TailscaleEndpointData] = []
if let iterator = status.endpoints() {
while iterator.hasNext() {
if let endpoint = iterator.next() {
endpoints.append(convertEndpoint(endpoint))
}
}
}
return endpoints
}
private static func convertEndpoint(_ endpoint: LibboxTailscaleEndpointStatus) -> TailscaleEndpointData {
var userGroups: [TailscaleUserGroupData] = []
if let groupIterator = endpoint.userGroups() {
while groupIterator.hasNext() {
if let group = groupIterator.next() {
userGroups.append(convertUserGroup(group))
}
}
}
return TailscaleEndpointData(
id: endpoint.endpointTag,
endpointTag: endpoint.endpointTag,
backendState: endpoint.backendState,
authURL: endpoint.authURL,
networkName: endpoint.networkName,
magicDNSSuffix: endpoint.magicDNSSuffix,
selfPeer: endpoint.self_ != nil ? convertPeer(endpoint.self_!) : nil,
userGroups: userGroups
)
}
private static func convertUserGroup(_ group: LibboxTailscaleUserGroup) -> TailscaleUserGroupData {
var peers: [TailscalePeerData] = []
if let peerIterator = group.peers() {
while peerIterator.hasNext() {
if let peer = peerIterator.next() {
peers.append(convertPeer(peer))
}
}
}
return TailscaleUserGroupData(
id: group.userID,
loginName: group.loginName,
displayName: group.displayName,
profilePicURL: group.profilePicURL,
peers: peers
)
}
private static func convertPeer(_ peer: LibboxTailscalePeer) -> TailscalePeerData {
var ips: [String] = []
if let ipIterator = peer.tailscaleIPs() {
while ipIterator.hasNext() {
ips.append(ipIterator.next())
}
}
return TailscalePeerData(
id: peer.dnsName.isEmpty ? peer.hostName : peer.dnsName,
hostName: peer.hostName,
dnsName: peer.dnsName,
os: peer.os,
tailscaleIPs: ips,
online: peer.online,
exitNode: peer.exitNode,
exitNodeOption: peer.exitNodeOption,
active: peer.active,
rxBytes: peer.rxBytes,
txBytes: peer.txBytes,
keyExpiry: peer.keyExpiry
)
}
}
}
@@ -1,10 +1,12 @@
import Library
import NetworkExtension
import SwiftUI
@MainActor
public struct ToolsView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = SettingViewModel()
@StateObject private var tailscaleViewModel = TailscaleStatusViewModel()
#if os(iOS)
@State private var showCrashReportList = false
@State private var showOOMReportList = false
@@ -14,6 +16,22 @@ public struct ToolsView: View {
public var body: some View {
FormView {
if !tailscaleViewModel.endpoints.isEmpty {
Section("Endpoints") {
ForEach(tailscaleViewModel.endpoints) { endpoint in
FormNavigationLink {
TailscaleEndpointView(viewModel: tailscaleViewModel, endpointTag: endpoint.endpointTag)
} label: {
if tailscaleViewModel.endpoints.count == 1 {
Label("Tailscale", systemImage: "point.3.filled.connected.trianglepath.dotted")
} else {
Label("Tailscale: \(endpoint.endpointTag)", systemImage: "point.3.filled.connected.trianglepath.dotted")
}
}
}
}
}
Section("Network") {
FormNavigationLink {
NetworkQualityView()
@@ -106,5 +124,42 @@ public struct ToolsView: View {
}
}
}
.modifier(TailscaleStatusObserver(profile: environments.extensionProfile, viewModel: tailscaleViewModel))
.alert($tailscaleViewModel.alert)
}
}
private struct TailscaleStatusObserver: ViewModifier {
var profile: ExtensionProfile?
var viewModel: TailscaleStatusViewModel
func body(content: Content) -> some View {
if let profile {
content
.modifier(ActiveObserver(profile: profile, viewModel: viewModel))
} else {
content
}
}
private struct ActiveObserver: ViewModifier {
@ObservedObject var profile: ExtensionProfile
var viewModel: TailscaleStatusViewModel
func body(content: Content) -> some View {
content
.onChangeCompat(of: profile.status) { status in
if status.isConnectedStrict {
viewModel.subscribe()
} else {
viewModel.cancel()
}
}
.onAppear {
if profile.status.isConnectedStrict {
viewModel.subscribe()
}
}
}
}
}