tools: Network Quality and STUN

This commit is contained in:
世界
2026-04-23 08:12:52 +08:00
parent 6559720e00
commit cd48f1abf6
24 changed files with 1537 additions and 501 deletions
@@ -32,7 +32,7 @@ public struct ConnectionView: View {
HStack {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text("\(connection.network.uppercased()) \(connection.displayDestination)")
Text(verbatim: "\(connection.network.uppercased()) \(connection.displayDestination)")
Spacer()
if connection.closedAt == nil {
Text("Active").foregroundStyle(.green)
@@ -45,8 +45,8 @@ public struct ConnectionView: View {
HStack {
if let closedAt = connection.closedAt {
VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.uploadTotal))")
Text("\(LibboxFormatBytes(connection.downloadTotal))")
Text(verbatim: "\(LibboxFormatBytes(connection.uploadTotal))")
Text(verbatim: "\(LibboxFormatBytes(connection.downloadTotal))")
}
.font(.caption2)
VStack(alignment: .leading) {
@@ -60,8 +60,8 @@ public struct ConnectionView: View {
}
} else {
VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.upload))/s")
Text("\(LibboxFormatBytes(connection.download))/s")
Text(verbatim: "\(LibboxFormatBytes(connection.upload))/s")
Text(verbatim: "\(LibboxFormatBytes(connection.download))/s")
}
.font(.caption2)
VStack(alignment: .leading) {
@@ -199,11 +199,11 @@ import SwiftUI
Spacer()
if !isProfileCard {
Toggle("", isOn: Binding(
Toggle(isOn: Binding(
get: { isEnabled },
set: { _ in onToggle() }
))
.labelsHidden()
)) {}
.labelsHidden()
}
Button {
@@ -13,24 +13,24 @@ public struct DownloadTrafficCard: View {
DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download")
if Variant.screenshotMode {
Text("249 MB/s")
Text(verbatim: "249 MB/s")
.font(.title2)
.fontWeight(.medium)
Text("5.6 GB")
Text(verbatim: "5.6 GB")
.font(.subheadline)
.foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.downlink))/s")
Text(verbatim: "\(LibboxFormatBytes(message.downlink))/s")
.font(.title2)
.fontWeight(.medium)
Text(LibboxFormatBytes(message.downlinkTotal))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("...")
Text(verbatim: "...")
.font(.title2)
.fontWeight(.medium)
Text("...")
Text(verbatim: "...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -22,7 +22,7 @@ public struct HTTPProxyCard: View {
HStack {
DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
Spacer()
Toggle("", isOn: $systemProxyEnabled)
Toggle(isOn: $systemProxyEnabled) {}
.labelsHidden()
#if os(macOS)
.toggleStyle(.switch)
@@ -13,24 +13,24 @@ public struct UploadTrafficCard: View {
DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload")
if Variant.screenshotMode {
Text("38 B/s")
Text(verbatim: "38 B/s")
.font(.title2)
.fontWeight(.medium)
Text("52 MB")
Text(verbatim: "52 MB")
.font(.subheadline)
.foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.uplink))/s")
Text(verbatim: "\(LibboxFormatBytes(message.uplink))/s")
.font(.title2)
.fontWeight(.medium)
Text(LibboxFormatBytes(message.uplinkTotal))
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("...")
Text(verbatim: "...")
.font(.title2)
.fontWeight(.medium)
Text("...")
Text(verbatim: "...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
@@ -41,13 +41,7 @@ public class GroupListViewModel: BaseViewModel {
var items = [OutboundGroupItem]()
let itemIterator = goGroup.getItems()!
while itemIterator.hasNext() {
let goItem = itemIterator.next()!
items.append(OutboundGroupItem(
tag: goItem.tag,
type: goItem.type,
urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)),
urlTestDelay: UInt16(goItem.urlTestDelay)
))
items.append(OutboundGroupItem(itemIterator.next()!))
}
var selected = goGroup.selected
@@ -18,7 +18,7 @@ public struct GroupView: View {
Text(group.displayType)
.font(.subheadline)
.foregroundColor(.secondary)
Text("\(group.items.count)")
Text(verbatim: "\(group.items.count)")
.font(.subheadline)
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.gray.opacity(0.5))
@@ -117,7 +117,7 @@ public struct EditProfileContentView: View {
private var defaultEditorView: some View {
#if os(tvOS)
ScrollView {
TextField("", text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical)
TextField(text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) {}
.lineLimit(1000)
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
@@ -80,7 +80,7 @@ public struct QRSDisplayView: View {
} label: {
Image(systemName: "minus")
}
Text("\(Int(sliceSize))")
Text(verbatim: "\(Int(sliceSize))")
.foregroundStyle(.secondary)
.frame(minWidth: 50)
Button {
@@ -89,7 +89,7 @@ public struct QRSDisplayView: View {
Image(systemName: "plus")
}
#else
Text("\(Int(sliceSize))")
Text(verbatim: "\(Int(sliceSize))")
.foregroundStyle(.secondary)
#endif
}
@@ -195,7 +195,7 @@
.animation(.easeInOut(duration: 0.2), value: progress)
if total > 0 {
Text("\(min(99, Int(progress * 100)))%")
Text(verbatim: "\(min(99, Int(progress * 100)))%")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
}
@@ -199,7 +199,7 @@ public struct AppView: View {
HStack {
Label("Update", systemImage: "arrow.down.circle")
Spacer()
Text("v\(info.versionName)")
Text(verbatim: "v\(info.versionName)")
.foregroundStyle(.secondary)
}
}
@@ -793,7 +793,7 @@ private struct StringListSection: View {
Text(title)
Spacer()
if !items.isEmpty {
Text("\(items.count)")
Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary)
}
}
@@ -806,7 +806,7 @@ private struct StringListSection: View {
Text(title)
Spacer()
if !items.isEmpty {
Text("\(items.count)")
Text(verbatim: "\(items.count)")
.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)
}
}
}
}
}
+15 -2
View File
@@ -14,6 +14,19 @@ public struct ToolsView: View {
public var body: some View {
FormView {
Section("Network") {
FormNavigationLink {
NetworkQualityView()
} label: {
Label("Network Quality", systemImage: "network")
}
FormNavigationLink {
STUNTestView()
} label: {
Label("STUN Test", systemImage: "arrow.triangle.swap")
}
}
Section("Debug") {
#if os(iOS)
NavigationLink(isActive: $showCrashReportList) {
@@ -50,7 +63,7 @@ public struct ToolsView: View {
Label("Crash Report", systemImage: "ladybug.fill")
Spacer()
if environments.crashReportManager.unreadCount > 0 {
Text("\(environments.crashReportManager.unreadCount)")
Text(verbatim: "\(environments.crashReportManager.unreadCount)")
.foregroundStyle(.secondary)
}
}
@@ -69,7 +82,7 @@ public struct ToolsView: View {
Label("OOM Report", systemImage: "memorychip")
Spacer()
if environments.oomReportManager.unreadCount > 0 {
Text("\(environments.oomReportManager.unreadCount)")
Text(verbatim: "\(environments.oomReportManager.unreadCount)")
.foregroundStyle(.secondary)
}
}
+8
View File
@@ -125,6 +125,14 @@ public enum SharedPreferences {
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
public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: [])
+17
View File
@@ -66,6 +66,7 @@ public class CommandClient: ObservableObject {
case log
case clashMode
case connections
case outbounds
}
private let connectionTypes: [ConnectionType]
@@ -88,6 +89,7 @@ public class CommandClient: ObservableObject {
}
@Published public var groups: [LibboxOutboundGroup]?
@Published public var outbounds: [LibboxOutboundGroupItem]?
@Published public var logList: [LogEntry]
@Published public var defaultLogLevel = 0
@Published public var selectedLogLevel: Int?
@@ -246,6 +248,8 @@ public class CommandClient: ObservableObject {
clientOptions.addCommand(LibboxCommandClashMode)
case .connections:
clientOptions.addCommand(LibboxCommandConnections)
case .outbounds:
clientOptions.addCommand(LibboxCommandOutbounds)
}
}
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?) {
DispatchQueue.main.async { [self] in
guard isActiveConnection() else { return }
+9
View File
@@ -48,6 +48,15 @@ public struct OutboundGroupItem: Codable, Hashable {
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 {
LibboxProxyDisplayType(type)
}
+676 -463
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -21,7 +21,9 @@ struct EditProfileContentWindow: View {
var body: some View {
Group {
if viewModel.isLoading {
if context == nil {
Color.clear
} else if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task {
@@ -37,8 +39,13 @@ struct EditProfileContentWindow: View {
.frame(minWidth: 600, minHeight: 400)
.background(WindowAccessor { window in
guard let window else { return }
if context == nil {
window.close()
return
}
if windowState.window == nil {
windowState.window = window
window.isRestorable = false
windowState.onClose = { [weak viewModel] in
viewModel?.reset()
}
+5 -1
View File
@@ -30,7 +30,11 @@ struct MainView: View {
.tag(page)
.tabItem {
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 {
page.label
}