tools: Tailscale status
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user