From cd48f1abf641337d4adc18a16118312c2addf175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 8 Apr 2026 16:25:43 +0800 Subject: [PATCH] tools: Network Quality and STUN --- .../Views/Connections/ConnectionView.swift | 10 +- .../Dashboard/Cards/CardManagementSheet.swift | 6 +- .../Dashboard/Cards/DownloadTrafficCard.swift | 10 +- .../Views/Dashboard/Cards/HTTPProxyCard.swift | 2 +- .../Dashboard/Cards/UploadTrafficCard.swift | 10 +- .../Views/Groups/GroupListViewModel.swift | 8 +- .../Views/Groups/GroupView.swift | 2 +- .../Profile/EditProfileContentView.swift | 2 +- .../Views/Profile/QRSDisplayView.swift | 4 +- .../Views/Scanner/QRScannerView.swift | 2 +- .../Views/Setting/MacAppView.swift | 2 +- .../Views/Setting/OnDemandRulesView.swift | 4 +- .../Views/Tools/NetworkQualityView.swift | 136 ++ .../Views/Tools/NetworkQualityViewModel.swift | 239 ++++ .../Views/Tools/OutboundPickerView.swift | 127 ++ .../Views/Tools/STUNTestView.swift | 123 ++ .../Views/Tools/STUNTestViewModel.swift | 146 +++ .../Views/Tools/ToolsView.swift | 17 +- Library/Database/SharedPreferences.swift | 8 + Library/Network/CommandClient.swift | 17 + Library/Network/OutboundGroup.swift | 9 + Localizable.xcstrings | 1139 ++++++++++------- MacLibrary/EditProfileContentWindow.swift | 9 +- SFT/MainView.swift | 6 +- 24 files changed, 1537 insertions(+), 501 deletions(-) create mode 100644 ApplicationLibrary/Views/Tools/NetworkQualityView.swift create mode 100644 ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift create mode 100644 ApplicationLibrary/Views/Tools/OutboundPickerView.swift create mode 100644 ApplicationLibrary/Views/Tools/STUNTestView.swift create mode 100644 ApplicationLibrary/Views/Tools/STUNTestViewModel.swift diff --git a/ApplicationLibrary/Views/Connections/ConnectionView.swift b/ApplicationLibrary/Views/Connections/ConnectionView.swift index c048ea2..ca043c5 100644 --- a/ApplicationLibrary/Views/Connections/ConnectionView.swift +++ b/ApplicationLibrary/Views/Connections/ConnectionView.swift @@ -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) { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift index e1dad8b..509e0d8 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/CardManagementSheet.swift @@ -199,11 +199,11 @@ import SwiftUI Spacer() if !isProfileCard { - Toggle("", isOn: Binding( + Toggle(isOn: Binding( get: { isEnabled }, set: { _ in onToggle() } - )) - .labelsHidden() + )) {} + .labelsHidden() } Button { diff --git a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift index 1fb65b9..76f4e64 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/DownloadTrafficCard.swift @@ -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) } diff --git a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift index 39d46b7..4ccf8e9 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/HTTPProxyCard.swift @@ -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) diff --git a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift index a37c0f0..524a07e 100644 --- a/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift +++ b/ApplicationLibrary/Views/Dashboard/Cards/UploadTrafficCard.swift @@ -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) } diff --git a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift index 8ffd687..644f905 100644 --- a/ApplicationLibrary/Views/Groups/GroupListViewModel.swift +++ b/ApplicationLibrary/Views/Groups/GroupListViewModel.swift @@ -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 diff --git a/ApplicationLibrary/Views/Groups/GroupView.swift b/ApplicationLibrary/Views/Groups/GroupView.swift index 75aa3e1..f466905 100644 --- a/ApplicationLibrary/Views/Groups/GroupView.swift +++ b/ApplicationLibrary/Views/Groups/GroupView.swift @@ -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)) diff --git a/ApplicationLibrary/Views/Profile/EditProfileContentView.swift b/ApplicationLibrary/Views/Profile/EditProfileContentView.swift index da85386..f15060a 100644 --- a/ApplicationLibrary/Views/Profile/EditProfileContentView.swift +++ b/ApplicationLibrary/Views/Profile/EditProfileContentView.swift @@ -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) diff --git a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift index 2140824..8c5ccce 100644 --- a/ApplicationLibrary/Views/Profile/QRSDisplayView.swift +++ b/ApplicationLibrary/Views/Profile/QRSDisplayView.swift @@ -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 } diff --git a/ApplicationLibrary/Views/Scanner/QRScannerView.swift b/ApplicationLibrary/Views/Scanner/QRScannerView.swift index d7df5f0..9f25143 100644 --- a/ApplicationLibrary/Views/Scanner/QRScannerView.swift +++ b/ApplicationLibrary/Views/Scanner/QRScannerView.swift @@ -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) } diff --git a/ApplicationLibrary/Views/Setting/MacAppView.swift b/ApplicationLibrary/Views/Setting/MacAppView.swift index 247e67c..96d78ca 100644 --- a/ApplicationLibrary/Views/Setting/MacAppView.swift +++ b/ApplicationLibrary/Views/Setting/MacAppView.swift @@ -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) } } diff --git a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift index d68379b..86186bd 100644 --- a/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift +++ b/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift @@ -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) } } diff --git a/ApplicationLibrary/Views/Tools/NetworkQualityView.swift b/ApplicationLibrary/Views/Tools/NetworkQualityView.swift new file mode 100644 index 0000000..cdd9024 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/NetworkQualityView.swift @@ -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() + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift b/ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift new file mode 100644 index 0000000..58003dd --- /dev/null +++ b/ApplicationLibrary/Views/Tools/NetworkQualityViewModel.swift @@ -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? + private var standaloneTest: LibboxNetworkQualityTest? + private var runningTask: Task? + 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) + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/OutboundPickerView.swift b/ApplicationLibrary/Views/Tools/OutboundPickerView.swift new file mode 100644 index 0000000..f667efa --- /dev/null +++ b/ApplicationLibrary/Views/Tools/OutboundPickerView.swift @@ -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: 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) } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/STUNTestView.swift b/ApplicationLibrary/Views/Tools/STUNTestView.swift new file mode 100644 index 0000000..66eb330 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/STUNTestView.swift @@ -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() + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/STUNTestViewModel.swift b/ApplicationLibrary/Views/Tools/STUNTestViewModel.swift new file mode 100644 index 0000000..66715e5 --- /dev/null +++ b/ApplicationLibrary/Views/Tools/STUNTestViewModel.swift @@ -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? + private var standaloneTest: LibboxSTUNTest? + private var runningTask: Task? + + 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) + } + } + } + } +} diff --git a/ApplicationLibrary/Views/Tools/ToolsView.swift b/ApplicationLibrary/Views/Tools/ToolsView.swift index e344291..d7f3fbf 100644 --- a/ApplicationLibrary/Views/Tools/ToolsView.swift +++ b/ApplicationLibrary/Views/Tools/ToolsView.swift @@ -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) } } diff --git a/Library/Database/SharedPreferences.swift b/Library/Database/SharedPreferences.swift index e2c252f..3aaa0ea 100644 --- a/Library/Database/SharedPreferences.swift +++ b/Library/Database/SharedPreferences.swift @@ -125,6 +125,14 @@ public enum SharedPreferences { public static let disableDeprecatedWarnings = Preference("disable_deprecated_warnings", defaultValue: false) + // Tools + + public static let nqConfigURL = Preference("nq_config_url", defaultValue: "") + public static let nqSerial = Preference("nq_serial", defaultValue: false) + public static let nqHttp3 = Preference("nq_http3", defaultValue: false) + public static let nqMaxRuntime = Preference("nq_max_runtime", defaultValue: 30) + public static let stunServer = Preference("stun_server", defaultValue: "") + // Dashboard public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: []) diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 9dea6ee..69d16a6 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -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 } diff --git a/Library/Network/OutboundGroup.swift b/Library/Network/OutboundGroup.swift index 04891f0..a9441b7 100644 --- a/Library/Network/OutboundGroup.swift +++ b/Library/Network/OutboundGroup.swift @@ -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) } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b8c0b2d..3767efc 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1,52 +1,6 @@ { "sourceLanguage" : "en", "strings" : { - "" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - } - }, - "shouldTranslate" : false - }, - "..." : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "..." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "..." - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "..." - } - } - }, - "shouldTranslate" : false - }, "**If I’ve defended your modern life, please consider sponsoring me.**" : { "localizations" : { "fa" : { @@ -75,84 +29,6 @@ } } }, - "%@ (%lld)" : { - "shouldTranslate" : false - }, - "%@ %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ %2$@" - } - }, - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ %2$@" - } - } - }, - "shouldTranslate" : false - }, - "%@/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@/s" - } - } - }, - "shouldTranslate" : false - }, - "%lld" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld" - } - } - }, - "shouldTranslate" : false - }, "%lld Profiles" : { "localizations" : { "fa" : { @@ -181,213 +57,6 @@ } } }, - "%lld%%" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%lld%%" - } - } - }, - "shouldTranslate" : false - }, - "↑ %@" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@" - } - } - }, - "shouldTranslate" : false - }, - "↑ %@/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↑ %@/s" - } - } - }, - "shouldTranslate" : false - }, - "↓ %@" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@" - } - } - }, - "shouldTranslate" : false - }, - "↓ %@/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "↓ %@/s" - } - } - }, - "shouldTranslate" : false - }, - "5.6 GB" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "5.6 GB" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "5.6 GB" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "5.6 GB" - } - } - }, - "shouldTranslate" : false - }, - "38 B/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "38 B/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "38 B/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "38 B/s" - } - } - }, - "shouldTranslate" : false - }, - "52 MB" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "52 MB" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "52 MB" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "52 MB" - } - } - }, - "shouldTranslate" : false - }, - "249 MB/s" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "249 MB/s" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "249 MB/s" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "249 MB/s" - } - } - }, - "shouldTranslate" : false - }, "About" : { "localizations" : { "fa" : { @@ -1349,6 +1018,34 @@ } } }, + "Cancel Test" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "لغو تست" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Остановить тест" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消測試" + } + } + } + }, "Cellular" : { "localizations" : { "fa" : { @@ -1742,6 +1439,90 @@ } } }, + "Confidence High" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطمینان بالا" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Высокая уверенность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度高" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度高" + } + } + } + }, + "Confidence Low" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطمینان پایین" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Низкая уверенность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度低" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度低" + } + } + } + }, + "Confidence Medium" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اطمینان متوسط" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Средняя уверенность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "置信度中" + } + } + } + }, "Configuration" : { "localizations" : { "fa" : { @@ -2022,6 +1803,34 @@ } } }, + "Continue" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ادامه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "繼續" + } + } + } + }, "Copy" : { "localizations" : { "fa" : { @@ -2978,6 +2787,34 @@ } } }, + "Download RPM" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "RPM دانلود" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка RPM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "下载 RPM" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "下載 RPM" + } + } + } + }, "Edit" : { "localizations" : { "fa" : { @@ -3343,26 +3180,6 @@ } }, "enforceRoutes" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "enforceRoutes" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "enforceRoutes" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "enforceRoutes" - } - } - }, "shouldTranslate" : false }, "Error" : { @@ -3506,75 +3323,15 @@ } }, "excludeAPNs" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeAPNs" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeAPNs" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeAPNs" - } - } - }, "shouldTranslate" : false }, "excludeCellularServices" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeCellularServices" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeCellularServices" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeCellularServices" - } - } - }, "shouldTranslate" : false }, "excludeDeviceCommunication" : { "shouldTranslate" : false }, "excludeLocalNetworks" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeLocalNetworks" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeLocalNetworks" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "excludeLocalNetworks" - } - } - }, "shouldTranslate" : false }, "Export Complete" : { @@ -3633,6 +3390,34 @@ } } }, + "External Address" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "آدرس خارجی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внешний адрес" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部地址" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部地址" + } + } + } + }, "Failed to decode QRS data" : { "localizations" : { "fa" : { @@ -4292,6 +4077,9 @@ } }, "shouldTranslate" : false + }, + "HTTP/3" : { + }, "https://sing-box.sagernet.org/" : { "localizations" : { @@ -4400,6 +4188,34 @@ }, "shouldTranslate" : false }, + "Idle Latency" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأخیر بیکاری" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Задержка в простое" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空闲延迟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "閒置延遲" + } + } + } + }, "If set, a request is sent to this URL. If it doesn't return HTTP 200, VPN is started." : { "localizations" : { "fa" : { @@ -4824,26 +4640,6 @@ } }, "includeAllNetworks" : { - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "includeAllNetworks" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "includeAllNetworks" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "includeAllNetworks" - } - } - }, "shouldTranslate" : false }, "Install" : { @@ -5182,6 +4978,34 @@ } } }, + "Latency" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تأخیر" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Задержка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "延迟" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "延遲" + } + } + } + }, "Launch the application when the system is logged in. If enabled at the same time as `Show in Menu Bar` and `Keep Menu Bar in Background`, the application interface will not be opened automatically." : { "localizations" : { "fa" : { @@ -5430,6 +5254,34 @@ }, "shouldTranslate" : false }, + "Max Runtime" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "حداکثر زمان اجرا" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Макс. время" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大运行时间" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大執行時間" + } + } + } + }, "Memory" : { "localizations" : { "fa" : { @@ -5514,6 +5366,34 @@ } } }, + "Metered Connection" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "اتصال محدود" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лимитное подключение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按流量计费连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "按流量計費連線" + } + } + } + }, "Missing access to selected file" : { "localizations" : { "fa" : { @@ -5738,6 +5618,90 @@ } } }, + "NAT Filtering" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فیلتر NAT" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT-фильтрация" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 过滤" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 過濾" + } + } + } + }, + "NAT Mapping" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نگاشت NAT" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT-отображение" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 映射" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 映射" + } + } + } + }, + "NAT Type Detection" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تشخیص نوع NAT" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Определение типа NAT" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 类型检测" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "NAT 類型偵測" + } + } + } + }, "Native Crash" : { "shouldTranslate" : false }, @@ -5797,6 +5761,34 @@ } } }, + "Network Quality" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "کیفیت شبکه" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Качество сети" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络质量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "網路品質" + } + } + } + }, "NetworkExtension" : { "shouldTranslate" : false }, @@ -6055,6 +6047,34 @@ } } }, + "Not supported by server" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پشتیبانی نمی‌شود توسط سرور" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не поддерживается сервером" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器不支持" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器不支援" + } + } + } + }, "Ok" : { "localizations" : { "fa" : { @@ -7166,6 +7186,34 @@ } } }, + "Results" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "نتایج" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Результаты" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "结果" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "結果" + } + } + } + }, "Resume" : { "comment" : "Resume log auto-scroll", "localizations" : { @@ -7478,6 +7526,62 @@ } } }, + "Serial" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "ترتیبی" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последовательно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "串行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "序列" + } + } + } + }, + "Server" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سرور" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сервер" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器" + } + } + } + }, "Service is Running" : { "localizations" : { "fa" : { @@ -8061,6 +8165,34 @@ } } }, + "Start Test" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شروع تست" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Начать тест" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始測試" + } + } + } + }, "Start the VPN connection when conditions match." : { "localizations" : { "fa" : { @@ -8229,6 +8361,63 @@ } } }, + "STUN Server" : { + "extractionState" : "stale", + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "سرور STUN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN-сервер" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 服务器" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 伺服器" + } + } + } + }, + "STUN Test" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "تست STUN" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN-тест" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 测试" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "STUN 測試" + } + } + } + }, "System Default" : { "localizations" : { "fa" : { @@ -8733,35 +8922,6 @@ } } }, - "Trigger OOM Report" : { - "extractionState" : "stale", - "localizations" : { - "fa" : { - "stringUnit" : { - "state" : "translated", - "value" : "ایجاد گزارش کمبود حافظه" - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Вызвать отчёт о нехватке памяти" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "触发内存不足报告" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "觸發記憶體不足報告" - } - } - } - }, "true" : { "localizations" : { "fa" : { @@ -9154,6 +9314,34 @@ } } }, + "Upload RPM" : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "RPM آپلود" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправка RPM" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上传 RPM" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上傳 RPM" + } + } + } + }, "URL" : { "localizations" : { "fa" : { @@ -9228,9 +9416,6 @@ } } }, - "v%@" : { - "shouldTranslate" : false - }, "Version" : { "localizations" : { "fa" : { @@ -9622,6 +9807,34 @@ } } } + }, + "You're on a metered connection. This test will use a significant amount of data." : { + "localizations" : { + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "شما از اتصال محدود استفاده می‌کنید. این تست حجم زیادی از داده‌ها را مصرف خواهد کرد." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы используете лимитное подключение. Этот тест потребляет значительный объём данных." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你正在使用按流量计费的连接。此测试将使用大量数据。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你正在使用按流量計費的連線。此測試將使用大量數據。" + } + } + } } }, "version" : "1.0" diff --git a/MacLibrary/EditProfileContentWindow.swift b/MacLibrary/EditProfileContentWindow.swift index 4f6bd5e..4b31e68 100644 --- a/MacLibrary/EditProfileContentWindow.swift +++ b/MacLibrary/EditProfileContentWindow.swift @@ -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() } diff --git a/SFT/MainView.swift b/SFT/MainView.swift index b7f91f5..a62a5ff 100644 --- a/SFT/MainView.swift +++ b/SFT/MainView.swift @@ -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 }