tools: Network Quality and STUN
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [])
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user