tools: Network Quality and STUN
This commit is contained in:
@@ -32,7 +32,7 @@ public struct ConnectionView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Text("\(connection.network.uppercased()) \(connection.displayDestination)")
|
Text(verbatim: "\(connection.network.uppercased()) \(connection.displayDestination)")
|
||||||
Spacer()
|
Spacer()
|
||||||
if connection.closedAt == nil {
|
if connection.closedAt == nil {
|
||||||
Text("Active").foregroundStyle(.green)
|
Text("Active").foregroundStyle(.green)
|
||||||
@@ -45,8 +45,8 @@ public struct ConnectionView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if let closedAt = connection.closedAt {
|
if let closedAt = connection.closedAt {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("↑ \(LibboxFormatBytes(connection.uploadTotal))")
|
Text(verbatim: "↑ \(LibboxFormatBytes(connection.uploadTotal))")
|
||||||
Text("↓ \(LibboxFormatBytes(connection.downloadTotal))")
|
Text(verbatim: "↓ \(LibboxFormatBytes(connection.downloadTotal))")
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -60,8 +60,8 @@ public struct ConnectionView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("↑ \(LibboxFormatBytes(connection.upload))/s")
|
Text(verbatim: "↑ \(LibboxFormatBytes(connection.upload))/s")
|
||||||
Text("↓ \(LibboxFormatBytes(connection.download))/s")
|
Text(verbatim: "↓ \(LibboxFormatBytes(connection.download))/s")
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
|||||||
@@ -199,10 +199,10 @@ import SwiftUI
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if !isProfileCard {
|
if !isProfileCard {
|
||||||
Toggle("", isOn: Binding(
|
Toggle(isOn: Binding(
|
||||||
get: { isEnabled },
|
get: { isEnabled },
|
||||||
set: { _ in onToggle() }
|
set: { _ in onToggle() }
|
||||||
))
|
)) {}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,24 +13,24 @@ public struct DownloadTrafficCard: View {
|
|||||||
DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download")
|
DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download")
|
||||||
|
|
||||||
if Variant.screenshotMode {
|
if Variant.screenshotMode {
|
||||||
Text("249 MB/s")
|
Text(verbatim: "249 MB/s")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text("5.6 GB")
|
Text(verbatim: "5.6 GB")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let message = commandClient.status, message.trafficAvailable {
|
} else if let message = commandClient.status, message.trafficAvailable {
|
||||||
Text("\(LibboxFormatBytes(message.downlink))/s")
|
Text(verbatim: "\(LibboxFormatBytes(message.downlink))/s")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text(LibboxFormatBytes(message.downlinkTotal))
|
Text(LibboxFormatBytes(message.downlinkTotal))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("...")
|
Text(verbatim: "...")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text("...")
|
Text(verbatim: "...")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public struct HTTPProxyCard: View {
|
|||||||
HStack {
|
HStack {
|
||||||
DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
|
DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
|
||||||
Spacer()
|
Spacer()
|
||||||
Toggle("", isOn: $systemProxyEnabled)
|
Toggle(isOn: $systemProxyEnabled) {}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
|
|||||||
@@ -13,24 +13,24 @@ public struct UploadTrafficCard: View {
|
|||||||
DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload")
|
DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload")
|
||||||
|
|
||||||
if Variant.screenshotMode {
|
if Variant.screenshotMode {
|
||||||
Text("38 B/s")
|
Text(verbatim: "38 B/s")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text("52 MB")
|
Text(verbatim: "52 MB")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else if let message = commandClient.status, message.trafficAvailable {
|
} else if let message = commandClient.status, message.trafficAvailable {
|
||||||
Text("\(LibboxFormatBytes(message.uplink))/s")
|
Text(verbatim: "\(LibboxFormatBytes(message.uplink))/s")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text(LibboxFormatBytes(message.uplinkTotal))
|
Text(LibboxFormatBytes(message.uplinkTotal))
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("...")
|
Text(verbatim: "...")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
Text("...")
|
Text(verbatim: "...")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,13 +41,7 @@ public class GroupListViewModel: BaseViewModel {
|
|||||||
var items = [OutboundGroupItem]()
|
var items = [OutboundGroupItem]()
|
||||||
let itemIterator = goGroup.getItems()!
|
let itemIterator = goGroup.getItems()!
|
||||||
while itemIterator.hasNext() {
|
while itemIterator.hasNext() {
|
||||||
let goItem = itemIterator.next()!
|
items.append(OutboundGroupItem(itemIterator.next()!))
|
||||||
items.append(OutboundGroupItem(
|
|
||||||
tag: goItem.tag,
|
|
||||||
type: goItem.type,
|
|
||||||
urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)),
|
|
||||||
urlTestDelay: UInt16(goItem.urlTestDelay)
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected = goGroup.selected
|
var selected = goGroup.selected
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public struct GroupView: View {
|
|||||||
Text(group.displayType)
|
Text(group.displayType)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("\(group.items.count)")
|
Text(verbatim: "\(group.items.count)")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
|
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
|
||||||
.background(Color.gray.opacity(0.5))
|
.background(Color.gray.opacity(0.5))
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ public struct EditProfileContentView: View {
|
|||||||
private var defaultEditorView: some View {
|
private var defaultEditorView: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
ScrollView {
|
ScrollView {
|
||||||
TextField("", text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical)
|
TextField(text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) {}
|
||||||
.lineLimit(1000)
|
.lineLimit(1000)
|
||||||
.font(Font.system(.caption2, design: .monospaced))
|
.font(Font.system(.caption2, design: .monospaced))
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ public struct QRSDisplayView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "minus")
|
Image(systemName: "minus")
|
||||||
}
|
}
|
||||||
Text("\(Int(sliceSize))")
|
Text(verbatim: "\(Int(sliceSize))")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(minWidth: 50)
|
.frame(minWidth: 50)
|
||||||
Button {
|
Button {
|
||||||
@@ -89,7 +89,7 @@ public struct QRSDisplayView: View {
|
|||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
Text("\(Int(sliceSize))")
|
Text(verbatim: "\(Int(sliceSize))")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,7 +195,7 @@
|
|||||||
.animation(.easeInOut(duration: 0.2), value: progress)
|
.animation(.easeInOut(duration: 0.2), value: progress)
|
||||||
|
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
Text("\(min(99, Int(progress * 100)))%")
|
Text(verbatim: "\(min(99, Int(progress * 100)))%")
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ public struct AppView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Label("Update", systemImage: "arrow.down.circle")
|
Label("Update", systemImage: "arrow.down.circle")
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("v\(info.versionName)")
|
Text(verbatim: "v\(info.versionName)")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -793,7 +793,7 @@ private struct StringListSection: View {
|
|||||||
Text(title)
|
Text(title)
|
||||||
Spacer()
|
Spacer()
|
||||||
if !items.isEmpty {
|
if !items.isEmpty {
|
||||||
Text("\(items.count)")
|
Text(verbatim: "\(items.count)")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,7 +806,7 @@ private struct StringListSection: View {
|
|||||||
Text(title)
|
Text(title)
|
||||||
Spacer()
|
Spacer()
|
||||||
if !items.isEmpty {
|
if !items.isEmpty {
|
||||||
Text("\(items.count)")
|
Text(verbatim: "\(items.count)")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct NetworkQualityView: View {
|
||||||
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
@StateObject private var viewModel = NetworkQualityViewModel()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
private var downloadActive: Bool {
|
||||||
|
(viewModel.isRunning && !viewModel.serial && viewModel.phase >= LibboxNetworkQualityPhaseDownload && viewModel.phase < LibboxNetworkQualityPhaseDone)
|
||||||
|
|| viewModel.phase == LibboxNetworkQualityPhaseDownload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accuracyLabel(_ value: Int32) -> (label: String, color: Color) {
|
||||||
|
switch value {
|
||||||
|
case LibboxNetworkQualityAccuracyHigh:
|
||||||
|
return (String(localized: "Confidence High"), .green)
|
||||||
|
case LibboxNetworkQualityAccuracyMedium:
|
||||||
|
return (String(localized: "Confidence Medium"), .yellow)
|
||||||
|
default:
|
||||||
|
return (String(localized: "Confidence Low"), .red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var uploadActive: Bool {
|
||||||
|
(viewModel.isRunning && !viewModel.serial && viewModel.phase >= LibboxNetworkQualityPhaseDownload && viewModel.phase < LibboxNetworkQualityPhaseDone)
|
||||||
|
|| viewModel.phase == LibboxNetworkQualityPhaseUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func resultValue(_ value: String?, active: Bool, accuracy: (label: String, color: Color)? = nil) -> some View {
|
||||||
|
if let value {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if viewModel.isRunning, active {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
Text(value)
|
||||||
|
if let accuracy {
|
||||||
|
Text(accuracy.label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(accuracy.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if viewModel.isRunning, active {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text(verbatim: "-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
FormView {
|
||||||
|
Section("Configuration") {
|
||||||
|
#if os(tvOS)
|
||||||
|
FormTextItem("URL", "link") {
|
||||||
|
Text(viewModel.configURL)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
FormItem("URL") {
|
||||||
|
TextField(text: $viewModel.configURL) {}
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
#if os(iOS)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
Toggle("Serial", isOn: $viewModel.serial)
|
||||||
|
.disabled(viewModel.isRunning)
|
||||||
|
Toggle("HTTP/3", isOn: $viewModel.http3)
|
||||||
|
.disabled(viewModel.isRunning)
|
||||||
|
Picker("Max Runtime", selection: $viewModel.maxRuntime) {
|
||||||
|
ForEach(MaxRuntimeOption.allCases) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isRunning)
|
||||||
|
if let profile = environments.extensionProfile {
|
||||||
|
ToolOutboundSection(profile: profile, viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Action") {
|
||||||
|
if viewModel.isRunning {
|
||||||
|
FormButton {
|
||||||
|
viewModel.cancel()
|
||||||
|
} label: {
|
||||||
|
Label("Cancel Test", systemImage: "stop.fill")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FormButton {
|
||||||
|
viewModel.requestStartTest(vpnConnected: environments.extensionProfile?.status.isConnectedStrict == true)
|
||||||
|
} label: {
|
||||||
|
Label("Start Test", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.phase >= 0 {
|
||||||
|
Section("Results") {
|
||||||
|
FormTextItem("Idle Latency", "timer") {
|
||||||
|
resultValue(viewModel.idleLatencyMs > 0 ? "\(viewModel.idleLatencyMs) ms" : nil, active: viewModel.phase == LibboxNetworkQualityPhaseIdle)
|
||||||
|
}
|
||||||
|
FormTextItem("Download", "arrow.down.circle") {
|
||||||
|
resultValue(viewModel.downloadCapacity > 0 ? LibboxFormatBitrate(viewModel.downloadCapacity) : nil, active: downloadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.downloadCapacityAccuracy) : nil)
|
||||||
|
}
|
||||||
|
FormTextItem("Download RPM", "arrow.down.to.line") {
|
||||||
|
resultValue(viewModel.downloadRPM > 0 ? "\(viewModel.downloadRPM)" : nil, active: downloadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.downloadRPMAccuracy) : nil)
|
||||||
|
}
|
||||||
|
FormTextItem("Upload", "arrow.up.circle") {
|
||||||
|
resultValue(viewModel.uploadCapacity > 0 ? LibboxFormatBitrate(viewModel.uploadCapacity) : nil, active: uploadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.uploadCapacityAccuracy) : nil)
|
||||||
|
}
|
||||||
|
FormTextItem("Upload RPM", "arrow.up.to.line") {
|
||||||
|
resultValue(viewModel.uploadRPM > 0 ? "\(viewModel.uploadRPM)" : nil, active: uploadActive, accuracy: viewModel.phase == LibboxNetworkQualityPhaseDone ? accuracyLabel(viewModel.uploadRPMAccuracy) : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Network Quality")
|
||||||
|
.task {
|
||||||
|
await viewModel.loadPreferences()
|
||||||
|
}
|
||||||
|
.alert($viewModel.alert)
|
||||||
|
.onDisappear {
|
||||||
|
if viewModel.isRunning {
|
||||||
|
viewModel.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import Network
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public enum MaxRuntimeOption: Int, CaseIterable, Identifiable {
|
||||||
|
case thirty = 30
|
||||||
|
case sixty = 60
|
||||||
|
|
||||||
|
public var id: Int {
|
||||||
|
rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public var label: String {
|
||||||
|
"\(rawValue)s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class NetworkQualityViewModel: BaseViewModel, OutboundSelectable {
|
||||||
|
@Published public var phase: Int32 = -1
|
||||||
|
@Published public var idleLatencyMs: Int32 = 0
|
||||||
|
@Published public var downloadCapacity: Int64 = 0
|
||||||
|
@Published public var uploadCapacity: Int64 = 0
|
||||||
|
@Published public var downloadRPM: Int32 = 0
|
||||||
|
@Published public var uploadRPM: Int32 = 0
|
||||||
|
@Published public var downloadCapacityAccuracy: Int32 = 0
|
||||||
|
@Published public var uploadCapacityAccuracy: Int32 = 0
|
||||||
|
@Published public var downloadRPMAccuracy: Int32 = 0
|
||||||
|
@Published public var uploadRPMAccuracy: Int32 = 0
|
||||||
|
@Published public var isRunning = false
|
||||||
|
@Published public var selectedOutbound: String = ""
|
||||||
|
|
||||||
|
@Published public var configURL: String = LibboxNetworkQualityDefaultConfigURL {
|
||||||
|
didSet {
|
||||||
|
guard !isLoadingPreferences else { return }
|
||||||
|
saveConfigURLTask?.cancel()
|
||||||
|
saveConfigURLTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await SharedPreferences.nqConfigURL.set(configURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published public var serial: Bool = false {
|
||||||
|
didSet {
|
||||||
|
guard !isLoadingPreferences else { return }
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.nqSerial.set(serial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published public var http3: Bool = false {
|
||||||
|
didSet {
|
||||||
|
guard !isLoadingPreferences else { return }
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.nqHttp3.set(http3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published public var maxRuntime: MaxRuntimeOption = .thirty {
|
||||||
|
didSet {
|
||||||
|
guard !isLoadingPreferences else { return }
|
||||||
|
Task {
|
||||||
|
await SharedPreferences.nqMaxRuntime.set(maxRuntime.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isLoadingPreferences = false
|
||||||
|
private var saveConfigURLTask: Task<Void, Never>?
|
||||||
|
private var standaloneTest: LibboxNetworkQualityTest?
|
||||||
|
private var runningTask: Task<Void, Never>?
|
||||||
|
public func loadPreferences() async {
|
||||||
|
isLoadingPreferences = true
|
||||||
|
let savedURL = await SharedPreferences.nqConfigURL.get()
|
||||||
|
if !savedURL.isEmpty {
|
||||||
|
configURL = savedURL
|
||||||
|
}
|
||||||
|
serial = await SharedPreferences.nqSerial.get()
|
||||||
|
http3 = await SharedPreferences.nqHttp3.get()
|
||||||
|
let savedRuntime = await SharedPreferences.nqMaxRuntime.get()
|
||||||
|
maxRuntime = MaxRuntimeOption(rawValue: savedRuntime) ?? .thirty
|
||||||
|
isLoadingPreferences = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkMeteredNetwork() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
monitor.cancel()
|
||||||
|
continuation.resume(returning: path.isExpensive || path.usesInterfaceType(.cellular))
|
||||||
|
}
|
||||||
|
monitor.start(queue: DispatchQueue.global())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func requestStartTest(vpnConnected: Bool) {
|
||||||
|
Task {
|
||||||
|
let isMetered = await checkMeteredNetwork()
|
||||||
|
if isMetered {
|
||||||
|
alert = AlertState(
|
||||||
|
title: String(localized: "Metered Connection"),
|
||||||
|
message: String(localized: "You're on a metered connection. This test will use a significant amount of data."),
|
||||||
|
primaryButton: .cancel(),
|
||||||
|
secondaryButton: .destructive(String(localized: "Continue")) { [weak self] in
|
||||||
|
self?.startTest(vpnConnected: vpnConnected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startTest(vpnConnected: vpnConnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func startTest(vpnConnected: Bool) {
|
||||||
|
phase = -1
|
||||||
|
idleLatencyMs = 0
|
||||||
|
downloadCapacity = 0
|
||||||
|
uploadCapacity = 0
|
||||||
|
downloadRPM = 0
|
||||||
|
uploadRPM = 0
|
||||||
|
downloadCapacityAccuracy = 0
|
||||||
|
uploadCapacityAccuracy = 0
|
||||||
|
downloadRPMAccuracy = 0
|
||||||
|
uploadRPMAccuracy = 0
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
let configURL = configURL
|
||||||
|
let outboundTag = selectedOutbound
|
||||||
|
let serial = serial
|
||||||
|
let http3 = http3
|
||||||
|
let maxRuntimeSeconds = Int32(maxRuntime.rawValue)
|
||||||
|
|
||||||
|
if vpnConnected {
|
||||||
|
let handler = TestHandler(self)
|
||||||
|
runningTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.detached {
|
||||||
|
try LibboxNewStandaloneCommandClient()!.startNetworkQualityTest(configURL, outboundTag: outboundTag, serial: serial, maxRuntimeSeconds: maxRuntimeSeconds, http3: http3, handler: handler)
|
||||||
|
}.value
|
||||||
|
} catch {
|
||||||
|
guard let self else { return }
|
||||||
|
self.isRunning = false
|
||||||
|
self.alert = AlertState(action: "network quality test", error: error)
|
||||||
|
}
|
||||||
|
self?.runningTask = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let test = LibboxNewNetworkQualityTest()!
|
||||||
|
standaloneTest = test
|
||||||
|
let handler = TestHandler(self)
|
||||||
|
test.start(configURL, serial: serial, maxRuntimeSeconds: maxRuntimeSeconds, http3: http3, handler: handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func applyMetrics(phase: Int32, idleLatencyMs: Int32, downloadCapacity: Int64, uploadCapacity: Int64, downloadRPM: Int32, uploadRPM: Int32, downloadCapacityAccuracy: Int32, uploadCapacityAccuracy: Int32, downloadRPMAccuracy: Int32, uploadRPMAccuracy: Int32) {
|
||||||
|
self.phase = phase
|
||||||
|
self.idleLatencyMs = idleLatencyMs
|
||||||
|
self.downloadCapacity = downloadCapacity
|
||||||
|
self.uploadCapacity = uploadCapacity
|
||||||
|
self.downloadRPM = downloadRPM
|
||||||
|
self.uploadRPM = uploadRPM
|
||||||
|
self.downloadCapacityAccuracy = downloadCapacityAccuracy
|
||||||
|
self.uploadCapacityAccuracy = uploadCapacityAccuracy
|
||||||
|
self.downloadRPMAccuracy = downloadRPMAccuracy
|
||||||
|
self.uploadRPMAccuracy = uploadRPMAccuracy
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
runningTask?.cancel()
|
||||||
|
runningTask = nil
|
||||||
|
standaloneTest?.cancel()
|
||||||
|
standaloneTest = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestHandler: NSObject, LibboxNetworkQualityTestHandlerProtocol, @unchecked Sendable {
|
||||||
|
private weak var viewModel: NetworkQualityViewModel?
|
||||||
|
|
||||||
|
init(_ viewModel: NetworkQualityViewModel?) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func onProgress(_ progress: LibboxNetworkQualityProgress?) {
|
||||||
|
guard let progress else { return }
|
||||||
|
let phase = progress.phase
|
||||||
|
let idleLatencyMs = progress.idleLatencyMs
|
||||||
|
let downloadCapacity = progress.downloadCapacity
|
||||||
|
let uploadCapacity = progress.uploadCapacity
|
||||||
|
let downloadRPM = progress.downloadRPM
|
||||||
|
let uploadRPM = progress.uploadRPM
|
||||||
|
let downloadCapacityAccuracy = progress.downloadCapacityAccuracy
|
||||||
|
let uploadCapacityAccuracy = progress.uploadCapacityAccuracy
|
||||||
|
let downloadRPMAccuracy = progress.downloadRPMAccuracy
|
||||||
|
let uploadRPMAccuracy = progress.uploadRPMAccuracy
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.applyMetrics(phase: phase, idleLatencyMs: idleLatencyMs, downloadCapacity: downloadCapacity, uploadCapacity: uploadCapacity, downloadRPM: downloadRPM, uploadRPM: uploadRPM, downloadCapacityAccuracy: downloadCapacityAccuracy, uploadCapacityAccuracy: uploadCapacityAccuracy, downloadRPMAccuracy: downloadRPMAccuracy, uploadRPMAccuracy: uploadRPMAccuracy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onResult(_ result: LibboxNetworkQualityResult?) {
|
||||||
|
guard let result else { return }
|
||||||
|
let idleLatencyMs = result.idleLatencyMs
|
||||||
|
let downloadCapacity = result.downloadCapacity
|
||||||
|
let uploadCapacity = result.uploadCapacity
|
||||||
|
let downloadRPM = result.downloadRPM
|
||||||
|
let uploadRPM = result.uploadRPM
|
||||||
|
let downloadCapacityAccuracy = result.downloadCapacityAccuracy
|
||||||
|
let uploadCapacityAccuracy = result.uploadCapacityAccuracy
|
||||||
|
let downloadRPMAccuracy = result.downloadRPMAccuracy
|
||||||
|
let uploadRPMAccuracy = result.uploadRPMAccuracy
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.applyMetrics(phase: LibboxNetworkQualityPhaseDone, idleLatencyMs: idleLatencyMs, downloadCapacity: downloadCapacity, uploadCapacity: uploadCapacity, downloadRPM: downloadRPM, uploadRPM: uploadRPM, downloadCapacityAccuracy: downloadCapacityAccuracy, uploadCapacityAccuracy: uploadCapacityAccuracy, downloadRPMAccuracy: downloadRPMAccuracy, uploadRPMAccuracy: uploadRPMAccuracy)
|
||||||
|
viewModel.isRunning = false
|
||||||
|
viewModel.runningTask = nil
|
||||||
|
viewModel.standaloneTest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_ message: String?) {
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.isRunning = false
|
||||||
|
viewModel.runningTask = nil
|
||||||
|
viewModel.standaloneTest = nil
|
||||||
|
if let message {
|
||||||
|
viewModel.alert = AlertState(errorMessage: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public protocol OutboundSelectable: ObservableObject {
|
||||||
|
var selectedOutbound: String { get set }
|
||||||
|
var isRunning: Bool { get }
|
||||||
|
func cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ToolOutboundSection<VM: OutboundSelectable>: View {
|
||||||
|
@ObservedObject var profile: ExtensionProfile
|
||||||
|
@ObservedObject var viewModel: VM
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Group {
|
||||||
|
if profile.status.isConnectedStrict {
|
||||||
|
FormNavigationLink {
|
||||||
|
OutboundPickerView(selectedOutbound: $viewModel.selectedOutbound)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Outbound")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.selectedOutbound.isEmpty ? String(localized: "Default") : viewModel.selectedOutbound)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChangeCompat(of: profile.status) { status in
|
||||||
|
if !status.isConnectedStrict {
|
||||||
|
if viewModel.isRunning {
|
||||||
|
viewModel.cancel()
|
||||||
|
}
|
||||||
|
viewModel.selectedOutbound = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct OutboundPickerView: View {
|
||||||
|
@Binding var selectedOutbound: String
|
||||||
|
@StateObject private var commandClient = CommandClient(.outbounds)
|
||||||
|
@State private var outbounds: [OutboundGroupItem] = []
|
||||||
|
@State private var searchText = ""
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var filteredOutbounds: [OutboundGroupItem] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return outbounds
|
||||||
|
}
|
||||||
|
return outbounds.filter { $0.tag.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
List {
|
||||||
|
Button {
|
||||||
|
selectedOutbound = ""
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Default")
|
||||||
|
.foregroundStyle(.foreground)
|
||||||
|
Spacer()
|
||||||
|
if selectedOutbound.isEmpty {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
ForEach(filteredOutbounds, id: \.tag) { item in
|
||||||
|
Button {
|
||||||
|
selectedOutbound = item.tag
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.tag)
|
||||||
|
.foregroundStyle(.foreground)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack {
|
||||||
|
Text(item.displayType)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if item.urlTestDelay > 0 {
|
||||||
|
Text(item.delayString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(item.delayColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedOutbound == item.tag {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||||
|
#else
|
||||||
|
.searchable(text: $searchText)
|
||||||
|
#endif
|
||||||
|
.navigationTitle("Outbound")
|
||||||
|
.onAppear {
|
||||||
|
commandClient.connect()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
commandClient.disconnect()
|
||||||
|
}
|
||||||
|
.onReceive(commandClient.$outbounds) { goOutbounds in
|
||||||
|
guard let goOutbounds else { return }
|
||||||
|
outbounds = goOutbounds.map { OutboundGroupItem($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct STUNTestView: View {
|
||||||
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
|
@StateObject private var viewModel = STUNTestViewModel()
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
private func natMappingColor(_ value: Int32) -> Color {
|
||||||
|
switch value {
|
||||||
|
case LibboxNATMappingEndpointIndependent: .green
|
||||||
|
case LibboxNATMappingAddressDependent: .yellow
|
||||||
|
case LibboxNATMappingAddressAndPortDependent: .red
|
||||||
|
default: .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func natFilteringColor(_ value: Int32) -> Color {
|
||||||
|
switch value {
|
||||||
|
case LibboxNATFilteringEndpointIndependent: .green
|
||||||
|
case LibboxNATFilteringAddressDependent: .yellow
|
||||||
|
case LibboxNATFilteringAddressAndPortDependent: .red
|
||||||
|
default: .primary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func resultValue(_ value: String?, active: Bool) -> some View {
|
||||||
|
if let value {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if viewModel.isRunning, active {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
Text(value)
|
||||||
|
}
|
||||||
|
} else if viewModel.isRunning, active {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Text(verbatim: "-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
FormView {
|
||||||
|
Section("Configuration") {
|
||||||
|
#if os(tvOS)
|
||||||
|
FormTextItem("Server", "server.rack") {
|
||||||
|
Text(viewModel.server)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
FormItem(String(localized: "Server")) {
|
||||||
|
TextField(text: $viewModel.server) {}
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
#if os(iOS)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
if let profile = environments.extensionProfile {
|
||||||
|
ToolOutboundSection(profile: profile, viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Action") {
|
||||||
|
if viewModel.isRunning {
|
||||||
|
FormButton {
|
||||||
|
viewModel.cancel()
|
||||||
|
} label: {
|
||||||
|
Label("Cancel Test", systemImage: "stop.fill")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FormButton {
|
||||||
|
viewModel.startTest(vpnConnected: environments.extensionProfile?.status.isConnectedStrict == true)
|
||||||
|
} label: {
|
||||||
|
Label("Start Test", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.phase >= 0 {
|
||||||
|
Section("Results") {
|
||||||
|
FormTextItem("External Address", "network") {
|
||||||
|
resultValue(viewModel.externalAddr.isEmpty ? nil : viewModel.externalAddr, active: viewModel.phase == LibboxSTUNPhaseBinding)
|
||||||
|
}
|
||||||
|
FormTextItem("Latency", "timer") {
|
||||||
|
resultValue(viewModel.latencyMs > 0 ? "\(viewModel.latencyMs) ms" : nil, active: viewModel.phase == LibboxSTUNPhaseBinding)
|
||||||
|
}
|
||||||
|
if viewModel.phase == LibboxSTUNPhaseDone, !viewModel.natTypeSupported {
|
||||||
|
FormTextItem("NAT Type Detection", "exclamationmark.triangle") {
|
||||||
|
Text("Not supported by server")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FormTextItem("NAT Mapping", "arrow.left.arrow.right") {
|
||||||
|
resultValue(viewModel.natMapping > 0 ? LibboxFormatNATMapping(viewModel.natMapping) : nil, active: viewModel.phase == LibboxSTUNPhaseNATMapping)
|
||||||
|
.foregroundStyle(viewModel.natMapping > 0 ? natMappingColor(viewModel.natMapping) : .primary)
|
||||||
|
}
|
||||||
|
FormTextItem("NAT Filtering", "line.3.horizontal.decrease") {
|
||||||
|
resultValue(viewModel.natFiltering > 0 ? LibboxFormatNATFiltering(viewModel.natFiltering) : nil, active: viewModel.phase == LibboxSTUNPhaseNATFiltering)
|
||||||
|
.foregroundStyle(viewModel.natFiltering > 0 ? natFilteringColor(viewModel.natFiltering) : .primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("STUN Test")
|
||||||
|
.task {
|
||||||
|
await viewModel.loadPreferences()
|
||||||
|
}
|
||||||
|
.alert($viewModel.alert)
|
||||||
|
.onDisappear {
|
||||||
|
if viewModel.isRunning {
|
||||||
|
viewModel.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libbox
|
||||||
|
import Library
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class STUNTestViewModel: BaseViewModel, OutboundSelectable {
|
||||||
|
@Published public var phase: Int32 = -1
|
||||||
|
@Published public var externalAddr: String = ""
|
||||||
|
@Published public var latencyMs: Int32 = 0
|
||||||
|
@Published public var natMapping: Int32 = 0
|
||||||
|
@Published public var natFiltering: Int32 = 0
|
||||||
|
@Published public var natTypeSupported: Bool = false
|
||||||
|
@Published public var isRunning = false
|
||||||
|
@Published public var selectedOutbound: String = ""
|
||||||
|
|
||||||
|
@Published public var server: String = LibboxSTUNDefaultServer {
|
||||||
|
didSet {
|
||||||
|
guard !isLoadingPreferences else { return }
|
||||||
|
saveServerTask?.cancel()
|
||||||
|
saveServerTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await SharedPreferences.stunServer.set(server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isLoadingPreferences = false
|
||||||
|
private var saveServerTask: Task<Void, Never>?
|
||||||
|
private var standaloneTest: LibboxSTUNTest?
|
||||||
|
private var runningTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
public func loadPreferences() async {
|
||||||
|
isLoadingPreferences = true
|
||||||
|
let saved = await SharedPreferences.stunServer.get()
|
||||||
|
if !saved.isEmpty {
|
||||||
|
server = saved
|
||||||
|
}
|
||||||
|
isLoadingPreferences = false
|
||||||
|
}
|
||||||
|
|
||||||
|
public func startTest(vpnConnected: Bool) {
|
||||||
|
phase = -1
|
||||||
|
externalAddr = ""
|
||||||
|
latencyMs = 0
|
||||||
|
natMapping = 0
|
||||||
|
natFiltering = 0
|
||||||
|
natTypeSupported = false
|
||||||
|
isRunning = true
|
||||||
|
|
||||||
|
let server = server
|
||||||
|
let outboundTag = selectedOutbound
|
||||||
|
|
||||||
|
if vpnConnected {
|
||||||
|
let handler = TestHandler(self)
|
||||||
|
runningTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
try await Task.detached {
|
||||||
|
try LibboxNewStandaloneCommandClient()!.startSTUNTest(server, outboundTag: outboundTag, handler: handler)
|
||||||
|
}.value
|
||||||
|
} catch {
|
||||||
|
guard let self else { return }
|
||||||
|
self.isRunning = false
|
||||||
|
self.alert = AlertState(action: "STUN test", error: error)
|
||||||
|
}
|
||||||
|
self?.runningTask = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let test = LibboxNewSTUNTest()!
|
||||||
|
standaloneTest = test
|
||||||
|
let handler = TestHandler(self)
|
||||||
|
test.start(server, handler: handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
runningTask?.cancel()
|
||||||
|
runningTask = nil
|
||||||
|
standaloneTest?.cancel()
|
||||||
|
standaloneTest = nil
|
||||||
|
isRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestHandler: NSObject, LibboxSTUNTestHandlerProtocol, @unchecked Sendable {
|
||||||
|
private weak var viewModel: STUNTestViewModel?
|
||||||
|
|
||||||
|
init(_ viewModel: STUNTestViewModel?) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func onProgress(_ progress: LibboxSTUNTestProgress?) {
|
||||||
|
guard let progress else { return }
|
||||||
|
let phase = progress.phase
|
||||||
|
let externalAddr = progress.externalAddr
|
||||||
|
let latencyMs = progress.latencyMs
|
||||||
|
let natMapping = progress.natMapping
|
||||||
|
let natFiltering = progress.natFiltering
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.phase = phase
|
||||||
|
if !externalAddr.isEmpty {
|
||||||
|
viewModel.externalAddr = externalAddr
|
||||||
|
}
|
||||||
|
if latencyMs > 0 {
|
||||||
|
viewModel.latencyMs = latencyMs
|
||||||
|
}
|
||||||
|
viewModel.natMapping = natMapping
|
||||||
|
viewModel.natFiltering = natFiltering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onResult(_ result: LibboxSTUNTestResult?) {
|
||||||
|
guard let result else { return }
|
||||||
|
let externalAddr = result.externalAddr
|
||||||
|
let latencyMs = result.latencyMs
|
||||||
|
let natMapping = result.natMapping
|
||||||
|
let natFiltering = result.natFiltering
|
||||||
|
let natTypeSupported = result.natTypeSupported
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.phase = LibboxSTUNPhaseDone
|
||||||
|
viewModel.externalAddr = externalAddr
|
||||||
|
viewModel.latencyMs = latencyMs
|
||||||
|
viewModel.natMapping = natMapping
|
||||||
|
viewModel.natFiltering = natFiltering
|
||||||
|
viewModel.natTypeSupported = natTypeSupported
|
||||||
|
viewModel.isRunning = false
|
||||||
|
viewModel.runningTask = nil
|
||||||
|
viewModel.standaloneTest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func onError(_ message: String?) {
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard let viewModel, viewModel.isRunning else { return }
|
||||||
|
viewModel.isRunning = false
|
||||||
|
viewModel.runningTask = nil
|
||||||
|
viewModel.standaloneTest = nil
|
||||||
|
if let message {
|
||||||
|
viewModel.alert = AlertState(errorMessage: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,19 @@ public struct ToolsView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
FormView {
|
FormView {
|
||||||
|
Section("Network") {
|
||||||
|
FormNavigationLink {
|
||||||
|
NetworkQualityView()
|
||||||
|
} label: {
|
||||||
|
Label("Network Quality", systemImage: "network")
|
||||||
|
}
|
||||||
|
FormNavigationLink {
|
||||||
|
STUNTestView()
|
||||||
|
} label: {
|
||||||
|
Label("STUN Test", systemImage: "arrow.triangle.swap")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Debug") {
|
Section("Debug") {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
NavigationLink(isActive: $showCrashReportList) {
|
NavigationLink(isActive: $showCrashReportList) {
|
||||||
@@ -50,7 +63,7 @@ public struct ToolsView: View {
|
|||||||
Label("Crash Report", systemImage: "ladybug.fill")
|
Label("Crash Report", systemImage: "ladybug.fill")
|
||||||
Spacer()
|
Spacer()
|
||||||
if environments.crashReportManager.unreadCount > 0 {
|
if environments.crashReportManager.unreadCount > 0 {
|
||||||
Text("\(environments.crashReportManager.unreadCount)")
|
Text(verbatim: "\(environments.crashReportManager.unreadCount)")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +82,7 @@ public struct ToolsView: View {
|
|||||||
Label("OOM Report", systemImage: "memorychip")
|
Label("OOM Report", systemImage: "memorychip")
|
||||||
Spacer()
|
Spacer()
|
||||||
if environments.oomReportManager.unreadCount > 0 {
|
if environments.oomReportManager.unreadCount > 0 {
|
||||||
Text("\(environments.oomReportManager.unreadCount)")
|
Text(verbatim: "\(environments.oomReportManager.unreadCount)")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,14 @@ public enum SharedPreferences {
|
|||||||
|
|
||||||
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
|
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
|
||||||
|
public static let nqConfigURL = Preference<String>("nq_config_url", defaultValue: "")
|
||||||
|
public static let nqSerial = Preference<Bool>("nq_serial", defaultValue: false)
|
||||||
|
public static let nqHttp3 = Preference<Bool>("nq_http3", defaultValue: false)
|
||||||
|
public static let nqMaxRuntime = Preference<Int>("nq_max_runtime", defaultValue: 30)
|
||||||
|
public static let stunServer = Preference<String>("stun_server", defaultValue: "")
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
|
|
||||||
public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: [])
|
public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: [])
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ public class CommandClient: ObservableObject {
|
|||||||
case log
|
case log
|
||||||
case clashMode
|
case clashMode
|
||||||
case connections
|
case connections
|
||||||
|
case outbounds
|
||||||
}
|
}
|
||||||
|
|
||||||
private let connectionTypes: [ConnectionType]
|
private let connectionTypes: [ConnectionType]
|
||||||
@@ -88,6 +89,7 @@ public class CommandClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Published public var groups: [LibboxOutboundGroup]?
|
@Published public var groups: [LibboxOutboundGroup]?
|
||||||
|
@Published public var outbounds: [LibboxOutboundGroupItem]?
|
||||||
@Published public var logList: [LogEntry]
|
@Published public var logList: [LogEntry]
|
||||||
@Published public var defaultLogLevel = 0
|
@Published public var defaultLogLevel = 0
|
||||||
@Published public var selectedLogLevel: Int?
|
@Published public var selectedLogLevel: Int?
|
||||||
@@ -246,6 +248,8 @@ public class CommandClient: ObservableObject {
|
|||||||
clientOptions.addCommand(LibboxCommandClashMode)
|
clientOptions.addCommand(LibboxCommandClashMode)
|
||||||
case .connections:
|
case .connections:
|
||||||
clientOptions.addCommand(LibboxCommandConnections)
|
clientOptions.addCommand(LibboxCommandConnections)
|
||||||
|
case .outbounds:
|
||||||
|
clientOptions.addCommand(LibboxCommandOutbounds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
clientOptions.statusInterval = Int64(NSEC_PER_SEC)
|
clientOptions.statusInterval = Int64(NSEC_PER_SEC)
|
||||||
@@ -384,6 +388,19 @@ public class CommandClient: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeOutbounds(_ message: (any LibboxOutboundGroupItemIteratorProtocol)?) {
|
||||||
|
guard let message else { return }
|
||||||
|
guard isActiveConnection() else { return }
|
||||||
|
var newOutbounds: [LibboxOutboundGroupItem] = []
|
||||||
|
while message.hasNext() {
|
||||||
|
newOutbounds.append(message.next()!)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [self] in
|
||||||
|
guard isActiveConnection() else { return }
|
||||||
|
commandClient.outbounds = newOutbounds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) {
|
func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) {
|
||||||
DispatchQueue.main.async { [self] in
|
DispatchQueue.main.async { [self] in
|
||||||
guard isActiveConnection() else { return }
|
guard isActiveConnection() else { return }
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ public struct OutboundGroupItem: Codable, Hashable {
|
|||||||
self.urlTestDelay = urlTestDelay
|
self.urlTestDelay = urlTestDelay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public init(_ item: LibboxOutboundGroupItem) {
|
||||||
|
self.init(
|
||||||
|
tag: item.tag,
|
||||||
|
type: item.type,
|
||||||
|
urlTestTime: Date(timeIntervalSince1970: Double(item.urlTestTime)),
|
||||||
|
urlTestDelay: UInt16(item.urlTestDelay)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
public var displayType: String {
|
public var displayType: String {
|
||||||
LibboxProxyDisplayType(type)
|
LibboxProxyDisplayType(type)
|
||||||
}
|
}
|
||||||
|
|||||||
+676
-463
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ struct EditProfileContentWindow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if viewModel.isLoading {
|
if context == nil {
|
||||||
|
Color.clear
|
||||||
|
} else if viewModel.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.task {
|
.task {
|
||||||
@@ -37,8 +39,13 @@ struct EditProfileContentWindow: View {
|
|||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
.background(WindowAccessor { window in
|
.background(WindowAccessor { window in
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
|
if context == nil {
|
||||||
|
window.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
if windowState.window == nil {
|
if windowState.window == nil {
|
||||||
windowState.window = window
|
windowState.window = window
|
||||||
|
window.isRestorable = false
|
||||||
windowState.onClose = { [weak viewModel] in
|
windowState.onClose = { [weak viewModel] in
|
||||||
viewModel?.reset()
|
viewModel?.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -30,7 +30,11 @@ struct MainView: View {
|
|||||||
.tag(page)
|
.tag(page)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
if page == .tools, environments.totalUnreadReportCount > 0 {
|
if page == .tools, environments.totalUnreadReportCount > 0 {
|
||||||
Label("\(page.title) (\(environments.totalUnreadReportCount))", systemImage: "terminal.fill")
|
Label {
|
||||||
|
Text(verbatim: "\(page.title) (\(environments.totalUnreadReportCount))")
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "terminal.fill")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
page.label
|
page.label
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user