Compare commits
11
Commits
6559720e00
...
3964538f4b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3964538f4b | ||
|
|
e21ab46461 | ||
|
|
82a42ec40a | ||
|
|
3ed2341355 | ||
|
|
7a57cfeb88 | ||
|
|
dd5bb31c3f | ||
|
|
8480bc3bb6 | ||
|
|
d833ab870f | ||
|
|
22e1941e5e | ||
|
|
cd48f1abf6 | ||
|
|
26c2ae540b |
@@ -81,6 +81,14 @@ public final class NWSocket {
|
||||
try await sendAndAwait(content: LibboxEncodeChunkedMessage(data), timeout: timeout, phase: "write")
|
||||
}
|
||||
|
||||
public func readRaw(count: Int, timeout: TimeInterval = 60) async throws -> Data {
|
||||
try await receiveExactly(count: count, timeout: timeout, phase: "read raw body")
|
||||
}
|
||||
|
||||
public func writeRaw(_ data: Data, timeout: TimeInterval = 30) async throws {
|
||||
try await sendAndAwait(content: data, timeout: timeout, phase: "write raw body")
|
||||
}
|
||||
|
||||
public func send(_ data: Data?) {
|
||||
guard let data else {
|
||||
return
|
||||
|
||||
@@ -21,25 +21,27 @@ public enum ReportTransferMessageType: UInt8 {
|
||||
case ack = 3
|
||||
}
|
||||
|
||||
public struct ReportTransferPayload: Codable {
|
||||
public struct ReportTransferManifest: Codable {
|
||||
public var reportType: ReportType
|
||||
public var timestamp: TimeInterval
|
||||
public var files: [ReportTransferFile]
|
||||
public var totalBytes: UInt64
|
||||
public var files: [ReportTransferManifestFile]
|
||||
|
||||
public init(reportType: ReportType, timestamp: TimeInterval, files: [ReportTransferFile]) {
|
||||
public init(reportType: ReportType, timestamp: TimeInterval, totalBytes: UInt64, files: [ReportTransferManifestFile]) {
|
||||
self.reportType = reportType
|
||||
self.timestamp = timestamp
|
||||
self.totalBytes = totalBytes
|
||||
self.files = files
|
||||
}
|
||||
}
|
||||
|
||||
public struct ReportTransferFile: Codable {
|
||||
public struct ReportTransferManifestFile: Codable {
|
||||
public var name: String
|
||||
public var data: Data
|
||||
public var size: UInt64
|
||||
|
||||
public init(name: String, data: Data) {
|
||||
public init(name: String, size: UInt64) {
|
||||
self.name = name
|
||||
self.data = data
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +55,13 @@ public struct ReportTransferError: LocalizedError {
|
||||
|
||||
public enum ReportTransferService {
|
||||
public static let applicationServiceName = "sing-box:report-transfer"
|
||||
public static let fileChunkSize = 64 * 1024
|
||||
}
|
||||
|
||||
public enum ReportTransferMessage {
|
||||
public static func encodeReport(_ payload: ReportTransferPayload) throws -> Data {
|
||||
public static func encodeReport(_ manifest: ReportTransferManifest) throws -> Data {
|
||||
var data = Data([ReportTransferMessageType.report.rawValue])
|
||||
try data.append(BinaryEncoder().encode(payload))
|
||||
try data.append(BinaryEncoder().encode(manifest))
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -81,8 +84,8 @@ public enum ReportTransferMessage {
|
||||
return ReportTransferMessageType(rawValue: data[0])
|
||||
}
|
||||
|
||||
public static func decodeReport(_ data: Data) throws -> ReportTransferPayload {
|
||||
try BinaryDecoder().decode(ReportTransferPayload.self, from: data.dropFirst())
|
||||
public static func decodeReport(_ data: Data) throws -> ReportTransferManifest {
|
||||
try BinaryDecoder().decode(ReportTransferManifest.self, from: data.dropFirst())
|
||||
}
|
||||
|
||||
public static func decodeError(_ data: Data) -> String {
|
||||
|
||||
@@ -52,37 +52,25 @@
|
||||
beginBackgroundTask()
|
||||
defer { endBackgroundTask() }
|
||||
|
||||
var receivedCount = 0
|
||||
var lastReportType: ReportType?
|
||||
do {
|
||||
while true {
|
||||
let message = try await connection.read()
|
||||
guard let type = ReportTransferMessage.decodeType(message) else {
|
||||
continue
|
||||
}
|
||||
switch type {
|
||||
case .report:
|
||||
let payload = try ReportTransferMessage.decodeReport(message)
|
||||
try importReport(payload)
|
||||
lastReportType = payload.reportType
|
||||
receivedCount += 1
|
||||
case .complete:
|
||||
logger.info("report transfer server: received \(receivedCount) report(s)")
|
||||
if receivedCount > 0 {
|
||||
let reportType = lastReportType
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .reportReceived, object: reportType)
|
||||
}
|
||||
}
|
||||
try await connection.write(ReportTransferMessage.encodeAck())
|
||||
return
|
||||
case .error:
|
||||
let errorMsg = ReportTransferMessage.decodeError(message)
|
||||
logger.warning("report transfer server: client error: \(errorMsg)")
|
||||
return
|
||||
case .ack:
|
||||
return
|
||||
let message = try await connection.read()
|
||||
guard let type = ReportTransferMessage.decodeType(message) else {
|
||||
throw ReportTransferError("Invalid report transfer message")
|
||||
}
|
||||
switch type {
|
||||
case .report:
|
||||
let manifest = try ReportTransferMessage.decodeReport(message)
|
||||
try await importReport(manifest)
|
||||
logger.info("report transfer server: received report")
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .reportReceived, object: manifest.reportType)
|
||||
}
|
||||
try await connection.write(ReportTransferMessage.encodeAck())
|
||||
case .error:
|
||||
let errorMsg = ReportTransferMessage.decodeError(message)
|
||||
logger.warning("report transfer server: client error: \(errorMsg)")
|
||||
case .complete, .ack:
|
||||
throw ReportTransferError("Unexpected report transfer message")
|
||||
}
|
||||
} catch {
|
||||
logger.warning("report transfer server: \(error.localizedDescription)")
|
||||
@@ -90,21 +78,83 @@
|
||||
}
|
||||
}
|
||||
|
||||
private func importReport(_ payload: ReportTransferPayload) throws {
|
||||
let reportsDir = FilePath.workingDirectory.appendingPathComponent(payload.reportType.directoryName, isDirectory: true)
|
||||
private func importReport(_ manifest: ReportTransferManifest) async throws {
|
||||
guard !manifest.files.isEmpty else {
|
||||
throw ReportTransferError("Report is empty")
|
||||
}
|
||||
|
||||
let expectedBytes = manifest.files.reduce(0) { $0 + $1.size }
|
||||
guard expectedBytes == manifest.totalBytes else {
|
||||
throw ReportTransferError("Invalid report manifest")
|
||||
}
|
||||
|
||||
let reportsDir = FilePath.workingDirectory.appendingPathComponent(manifest.reportType.directoryName, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: reportsDir, withIntermediateDirectories: true)
|
||||
|
||||
let date = Date(timeIntervalSince1970: payload.timestamp)
|
||||
let date = Date(timeIntervalSince1970: manifest.timestamp)
|
||||
let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date)
|
||||
try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true)
|
||||
let stagingURL = nextAvailableStagingArtifactURL(in: reportsDir, for: artifactURL.lastPathComponent)
|
||||
try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: true)
|
||||
|
||||
for file in payload.files {
|
||||
let fileURL = artifactURL.appendingPathComponent(file.name)
|
||||
if file.name == ReportArchive.metadataFileName {
|
||||
try writeMetadataWithDeviceOrigin(file.data, to: fileURL)
|
||||
} else {
|
||||
try file.data.write(to: fileURL, options: .atomic)
|
||||
do {
|
||||
var receivedBytes: UInt64 = 0
|
||||
for file in manifest.files {
|
||||
let fileURL = stagingURL.appendingPathComponent(file.name)
|
||||
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
|
||||
do {
|
||||
let handle = try FileHandle(forWritingTo: fileURL)
|
||||
defer { try? handle.close() }
|
||||
|
||||
var remaining = file.size
|
||||
while remaining > 0 {
|
||||
let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining))
|
||||
let data = try await connection.readRaw(count: chunkSize)
|
||||
try handle.write(contentsOf: data)
|
||||
remaining -= UInt64(data.count)
|
||||
receivedBytes += UInt64(data.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard receivedBytes == manifest.totalBytes else {
|
||||
throw ReportTransferError("Report transfer was incomplete")
|
||||
}
|
||||
|
||||
let completion = try await connection.read()
|
||||
guard let completionType = ReportTransferMessage.decodeType(completion) else {
|
||||
throw ReportTransferError("Invalid report transfer message")
|
||||
}
|
||||
switch completionType {
|
||||
case .complete:
|
||||
break
|
||||
case .error:
|
||||
throw ReportTransferError(ReportTransferMessage.decodeError(completion))
|
||||
case .report, .ack:
|
||||
throw ReportTransferError("Unexpected report transfer message")
|
||||
}
|
||||
|
||||
let metadataURL = stagingURL.appendingPathComponent(ReportArchive.metadataFileName)
|
||||
if FileManager.default.fileExists(atPath: metadataURL.path) {
|
||||
let metadataData = try Data(contentsOf: metadataURL)
|
||||
try writeMetadataWithDeviceOrigin(metadataData, to: metadataURL)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: stagingURL, to: artifactURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: stagingURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func nextAvailableStagingArtifactURL(in directory: URL, for artifactName: String) -> URL {
|
||||
var index = 0
|
||||
while true {
|
||||
let suffix = index == 0 ? "" : "-\(index)"
|
||||
let candidate = directory.appendingPathComponent(".\(artifactName).partial\(suffix)", isDirectory: true)
|
||||
if !FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,16 +165,10 @@ public struct GlobalChecksModifier: ViewModifier {
|
||||
let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get()
|
||||
guard !disableWarnings else { return }
|
||||
|
||||
do {
|
||||
let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes()
|
||||
if reports.hasNext() {
|
||||
await MainActor.run {
|
||||
showNextDeprecatedNote(reports)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
guard let reports = try? LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() else { return }
|
||||
if reports.hasNext() {
|
||||
await MainActor.run {
|
||||
alert = AlertState(action: "check deprecated notes", error: error)
|
||||
showNextDeprecatedNote(reports)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,33 @@ public extension View {
|
||||
}
|
||||
#endif
|
||||
|
||||
public struct ActionIconButton: View {
|
||||
let systemImage: String
|
||||
let action: () -> Void
|
||||
|
||||
public init(_ systemImage: String, action: @escaping () -> Void) {
|
||||
self.systemImage = systemImage
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 12))
|
||||
#if !os(tvOS)
|
||||
.frame(width: 44, height: 32)
|
||||
.background(Color.secondary.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
#endif
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#if os(tvOS)
|
||||
.actionButtonStyle()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func cardStyle() -> some View {
|
||||
modifier(CardStyleModifier())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ public struct StartStopButton: View {
|
||||
|
||||
if !profile.status.isConnected {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
.padding(.horizontal, 12)
|
||||
} else {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public struct CrashReportListView: View {
|
||||
CrashTriggerView()
|
||||
}
|
||||
.toolbar {
|
||||
if SharedPreferences.inDebug {
|
||||
if Variant.inDebug {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
showCrashTrigger = true
|
||||
@@ -101,9 +101,9 @@ public struct CrashReportListView: View {
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
if !manager.reports.isEmpty || SharedPreferences.inDebug {
|
||||
if !manager.reports.isEmpty || Variant.inDebug {
|
||||
Menu {
|
||||
if SharedPreferences.inDebug {
|
||||
if Variant.inDebug {
|
||||
Menu {
|
||||
Menu("Application") {
|
||||
Button("Go Crash") {
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
private struct StreamedReportFile {
|
||||
let name: String
|
||||
let fileURL: URL
|
||||
let size: UInt64
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct ExportReportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -128,31 +134,23 @@
|
||||
}
|
||||
|
||||
private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws {
|
||||
let fm = FileManager.default
|
||||
guard let fileURLs = try? fm.contentsOfDirectory(
|
||||
at: reportURL,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: .skipsHiddenFiles
|
||||
) else {
|
||||
throw ReportTransferError("Report is empty")
|
||||
}
|
||||
|
||||
var files: [ReportTransferFile] = []
|
||||
for fileURL in fileURLs {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { continue }
|
||||
files.append(ReportTransferFile(name: fileURL.lastPathComponent, data: data))
|
||||
}
|
||||
|
||||
let files = try collectFiles(in: reportURL)
|
||||
guard !files.isEmpty else {
|
||||
throw ReportTransferError("Report is empty")
|
||||
}
|
||||
|
||||
let payload = ReportTransferPayload(
|
||||
let totalBytes = files.reduce(0) { $0 + $1.size }
|
||||
let manifest = ReportTransferManifest(
|
||||
reportType: reportType,
|
||||
timestamp: reportDate.timeIntervalSince1970,
|
||||
files: files
|
||||
totalBytes: totalBytes,
|
||||
files: files.map { ReportTransferManifestFile(name: $0.name, size: $0.size) }
|
||||
)
|
||||
try await socket.write(ReportTransferMessage.encodeReport(payload))
|
||||
try await socket.write(ReportTransferMessage.encodeReport(manifest))
|
||||
|
||||
for file in files {
|
||||
try await streamFile(file, via: socket)
|
||||
}
|
||||
try await socket.write(ReportTransferMessage.encodeComplete())
|
||||
|
||||
let response = try await socket.read()
|
||||
@@ -168,6 +166,40 @@
|
||||
throw NWSocketError.connectionClosed
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func collectFiles(in reportURL: URL) throws -> [StreamedReportFile] {
|
||||
let fm = FileManager.default
|
||||
let fileURLs = try fm.contentsOfDirectory(
|
||||
at: reportURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
|
||||
options: .skipsHiddenFiles
|
||||
)
|
||||
var files: [StreamedReportFile] = []
|
||||
for fileURL in fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
|
||||
guard values.isRegularFile == true else {
|
||||
continue
|
||||
}
|
||||
let size = UInt64(values.fileSize ?? 0)
|
||||
files.append(StreamedReportFile(name: fileURL.lastPathComponent, fileURL: fileURL, size: size))
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private nonisolated func streamFile(_ file: StreamedReportFile, via socket: NWSocket) async throws {
|
||||
let handle = try FileHandle(forReadingFrom: file.fileURL)
|
||||
defer { try? handle.close() }
|
||||
|
||||
var remaining = file.size
|
||||
while remaining > 0 {
|
||||
let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining))
|
||||
guard let data = try handle.read(upToCount: chunkSize), !data.isEmpty else {
|
||||
throw ReportTransferError("Failed to read report file")
|
||||
}
|
||||
try await socket.writeRaw(data)
|
||||
remaining -= UInt64(data.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct TailscaleEndpointView: View {
|
||||
@ObservedObject var viewModel: TailscaleStatusViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showAuthURLQRCode = false
|
||||
let endpointTag: String
|
||||
|
||||
public init(viewModel: TailscaleStatusViewModel, endpointTag: String) {
|
||||
self.viewModel = viewModel
|
||||
self.endpointTag = endpointTag
|
||||
}
|
||||
|
||||
private var endpoint: TailscaleEndpointData? {
|
||||
viewModel.endpoint(tag: endpointTag)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
if let endpoint {
|
||||
Section("Status") {
|
||||
FormTextItem("State", "power") {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(stateColor(endpoint.backendState))
|
||||
Text(endpoint.backendState)
|
||||
}
|
||||
}
|
||||
if !endpoint.networkName.isEmpty {
|
||||
FormTextItem("Network", "network") {
|
||||
Text(endpoint.networkName)
|
||||
}
|
||||
}
|
||||
if !endpoint.magicDNSSuffix.isEmpty {
|
||||
FormTextItem("MagicDNS", "globe") {
|
||||
Text(endpoint.magicDNSSuffix)
|
||||
}
|
||||
}
|
||||
if !endpoint.authURL.isEmpty {
|
||||
if let url = URL(string: endpoint.authURL) {
|
||||
#if !os(tvOS)
|
||||
Link(destination: url) {
|
||||
Label("Open Auth URL", systemImage: "arrow.up.forward.app")
|
||||
}
|
||||
#endif
|
||||
Button {
|
||||
showAuthURLQRCode = true
|
||||
} label: {
|
||||
Label("Open Auth URL as QR Code", systemImage: "qrcode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if endpoint.backendState == "Running", let selfPeer = endpoint.selfPeer {
|
||||
Section("This Device") {
|
||||
peerLink(selfPeer, isSelf: true)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(endpoint.userGroups) { group in
|
||||
Section {
|
||||
ForEach(group.peers) { peer in
|
||||
peerLink(peer, isSelf: false)
|
||||
}
|
||||
} header: {
|
||||
Text(group.displayName.isEmpty ? group.loginName : group.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(endpointTag)
|
||||
.sheet(isPresented: $showAuthURLQRCode) {
|
||||
if let endpoint {
|
||||
URLQRCodeSheet(url: endpoint.authURL, title: String(localized: "Auth URL"))
|
||||
}
|
||||
}
|
||||
.onChangeCompat(of: endpoint == nil) { isNil in
|
||||
if isNil {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func peerLink(_ peer: TailscalePeerData, isSelf: Bool) -> some View {
|
||||
FormNavigationLink {
|
||||
TailscalePeerView(peer: peer, endpointTag: endpointTag, isSelf: isSelf)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(peer.online ? .green : Color(.systemGray))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(peer.hostName)
|
||||
if let firstIP = peer.tailscaleIPs.first {
|
||||
Text(firstIP)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateColor(_ state: String) -> Color {
|
||||
switch state {
|
||||
case "Running": .green
|
||||
case "NeedsLogin", "NeedsMachineAuth": .orange
|
||||
case "Starting": .yellow
|
||||
default: Color(.systemGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
public struct TailscalePeerView: View {
|
||||
let peer: TailscalePeerData
|
||||
let endpointTag: String
|
||||
let isSelf: Bool
|
||||
|
||||
@State private var copiedAddress: String?
|
||||
@StateObject private var pingViewModel = TailscalePingViewModel()
|
||||
|
||||
public init(peer: TailscalePeerData, endpointTag: String, isSelf: Bool) {
|
||||
self.peer = peer
|
||||
self.endpointTag = endpointTag
|
||||
self.isSelf = isSelf
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
Section("Tailscale Addresses") {
|
||||
if !peer.dnsName.isEmpty {
|
||||
addressRow(LibboxFormatFQDN(peer.dnsName), label: "MagicDNS")
|
||||
}
|
||||
ForEach(Array(peer.tailscaleIPs.enumerated()), id: \.offset) { _, ip in
|
||||
if ip.contains(":") {
|
||||
addressRow(ip, label: "IPv6")
|
||||
} else {
|
||||
addressRow(ip, label: "IPv4")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSelf, peer.online, let peerIP = peer.tailscaleIPs.first {
|
||||
Section {
|
||||
if pingViewModel.hasResult {
|
||||
connectionTypeRow
|
||||
}
|
||||
if pingViewModel.isRunning, pingViewModel.hasResult {
|
||||
pingChartView
|
||||
}
|
||||
if !pingViewModel.hasResult {
|
||||
Text("No data")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Ping")
|
||||
Spacer()
|
||||
ActionIconButton(pingViewModel.isRunning ? "stop.fill" : "play.fill") {
|
||||
if pingViewModel.isRunning {
|
||||
pingViewModel.stop()
|
||||
} else {
|
||||
pingViewModel.start(endpointTag: endpointTag, peerIP: peerIP)
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if peer.keyExpiry > 0 || !peer.os.isEmpty || peer.exitNode {
|
||||
Section("Details") {
|
||||
if peer.keyExpiry > 0 {
|
||||
FormTextItem("Key Expiry", "key") {
|
||||
Text(keyExpiryText)
|
||||
}
|
||||
}
|
||||
if !peer.os.isEmpty {
|
||||
FormTextItem("OS", "desktopcomputer") {
|
||||
Text(peer.os)
|
||||
}
|
||||
}
|
||||
if peer.exitNode {
|
||||
FormTextItem("Exit Node", "arrow.triangle.turn.up.right.diamond") {
|
||||
Text("Active")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack(spacing: 2) {
|
||||
Text(peer.hostName)
|
||||
.font(.headline)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(peer.online ? .green : Color(.systemGray))
|
||||
Text(peer.online ? "Connected" : "Not Connected")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if pingViewModel.isRunning {
|
||||
pingViewModel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionTypeRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
if pingViewModel.isDirect {
|
||||
Image(systemName: "arrow.right")
|
||||
.foregroundStyle(.green)
|
||||
Text("Direct connection")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundStyle(.orange)
|
||||
Text("DERP-relayed connection")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Spacer()
|
||||
Text(verbatim: "\(Int(pingViewModel.latencyMs)) ms")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
private var pingChartView: some View {
|
||||
#if os(tvOS)
|
||||
let chartHeight: CGFloat = 160
|
||||
let labelWidth: CGFloat = 80
|
||||
#else
|
||||
let chartHeight: CGFloat = 80
|
||||
let labelWidth: CGFloat = 50
|
||||
#endif
|
||||
return HStack(alignment: .center) {
|
||||
TrafficLineChart(
|
||||
data: pingViewModel.latencyHistory,
|
||||
lineColor: pingViewModel.isDirect ? .green : .blue,
|
||||
chartHeight: chartHeight
|
||||
)
|
||||
VStack(alignment: .trailing, spacing: 0) {
|
||||
let maxMs = max(Int((pingViewModel.latencyHistory.max() ?? 1) * 1.2), 1)
|
||||
Text(verbatim: "\(maxMs)ms")
|
||||
Spacer()
|
||||
Text(verbatim: "\(maxMs * 2 / 3)ms")
|
||||
Spacer()
|
||||
Text(verbatim: "\(maxMs / 3)ms")
|
||||
Spacer()
|
||||
Text(verbatim: "0ms")
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: labelWidth)
|
||||
}
|
||||
.frame(height: chartHeight)
|
||||
#if os(tvOS)
|
||||
.padding(.vertical, 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var keyExpiryText: String {
|
||||
let date = Date(timeIntervalSince1970: TimeInterval(peer.keyExpiry))
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
private func addressRow(_ address: String, label: String) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(address)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
#if !os(tvOS)
|
||||
Button {
|
||||
copyToClipboard(address)
|
||||
} label: {
|
||||
if copiedAddress == address {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func copyToClipboard(_ text: String) {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = text
|
||||
let generator = UINotificationFeedbackGenerator()
|
||||
generator.notificationOccurred(.success)
|
||||
#elseif os(macOS)
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(text, forType: .string)
|
||||
#endif
|
||||
withAnimation {
|
||||
copiedAddress = text
|
||||
}
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
|
||||
withAnimation {
|
||||
if copiedAddress == text {
|
||||
copiedAddress = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public final class TailscalePingViewModel: BaseViewModel {
|
||||
@Published public var isRunning = false
|
||||
@Published public var latencyMs: Double = 0
|
||||
@Published public var isDirect: Bool = false
|
||||
@Published public var derpRegionCode: String = ""
|
||||
@Published public var endpoint: String = ""
|
||||
@Published public var hasResult = false
|
||||
@Published public var latencyHistory: [CGFloat] = []
|
||||
|
||||
private let maxHistorySize = 30
|
||||
private var commandClient: LibboxCommandClient?
|
||||
private var runningTask: Task<Void, Never>?
|
||||
|
||||
public func start(endpointTag: String, peerIP: String) {
|
||||
latencyHistory = []
|
||||
hasResult = false
|
||||
isRunning = true
|
||||
|
||||
let client = LibboxNewStandaloneCommandClient()!
|
||||
commandClient = client
|
||||
let handler = PingHandler(self)
|
||||
|
||||
runningTask = Task { [weak self] in
|
||||
await Task.detached {
|
||||
try? client.startTailscalePing(endpointTag, peerIP: peerIP, handler: handler)
|
||||
}.value
|
||||
self?.runningTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
runningTask?.cancel()
|
||||
runningTask = nil
|
||||
try? commandClient?.disconnect()
|
||||
commandClient = nil
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
fileprivate func appendLatency(_ ms: Double) {
|
||||
latencyHistory.append(CGFloat(ms))
|
||||
if latencyHistory.count > maxHistorySize {
|
||||
latencyHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private final class PingHandler: NSObject, LibboxTailscalePingHandlerProtocol, @unchecked Sendable {
|
||||
private weak var viewModel: TailscalePingViewModel?
|
||||
|
||||
init(_ viewModel: TailscalePingViewModel?) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
func onPingResult(_ result: LibboxTailscalePingResult?) {
|
||||
guard let result else { return }
|
||||
let latencyMs = result.latencyMs
|
||||
let isDirect = result.isDirect
|
||||
let derpRegionCode = result.derpRegionCode
|
||||
let endpoint = result.endpoint
|
||||
let error = result.error
|
||||
DispatchQueue.main.async { [self] in
|
||||
guard let viewModel, viewModel.isRunning else { return }
|
||||
if !error.isEmpty {
|
||||
return
|
||||
}
|
||||
viewModel.latencyMs = latencyMs
|
||||
viewModel.isDirect = isDirect
|
||||
viewModel.derpRegionCode = derpRegionCode
|
||||
viewModel.endpoint = endpoint
|
||||
viewModel.hasResult = true
|
||||
viewModel.appendLatency(latencyMs)
|
||||
}
|
||||
}
|
||||
|
||||
func onError(_: String?) {
|
||||
DispatchQueue.main.async { [self] in
|
||||
guard let viewModel, viewModel.isRunning else { return }
|
||||
viewModel.isRunning = false
|
||||
viewModel.commandClient = nil
|
||||
viewModel.runningTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
import SwiftUI
|
||||
|
||||
public struct TailscalePeerData: Identifiable {
|
||||
public let id: String
|
||||
public let hostName: String
|
||||
public let dnsName: String
|
||||
public let os: String
|
||||
public let tailscaleIPs: [String]
|
||||
public let online: Bool
|
||||
public let exitNode: Bool
|
||||
public let exitNodeOption: Bool
|
||||
public let active: Bool
|
||||
public let rxBytes: Int64
|
||||
public let txBytes: Int64
|
||||
public let keyExpiry: Int64
|
||||
}
|
||||
|
||||
public struct TailscaleUserGroupData: Identifiable {
|
||||
public let id: Int64
|
||||
public let loginName: String
|
||||
public let displayName: String
|
||||
public let profilePicURL: String
|
||||
public let peers: [TailscalePeerData]
|
||||
}
|
||||
|
||||
public struct TailscaleEndpointData: Identifiable {
|
||||
public let id: String
|
||||
public let endpointTag: String
|
||||
public let backendState: String
|
||||
public let authURL: String
|
||||
public let networkName: String
|
||||
public let magicDNSSuffix: String
|
||||
public let selfPeer: TailscalePeerData?
|
||||
public let userGroups: [TailscaleUserGroupData]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class TailscaleStatusViewModel: BaseViewModel {
|
||||
@Published public var endpoints: [TailscaleEndpointData] = []
|
||||
@Published public var isSubscribed = false
|
||||
|
||||
private var runningTask: Task<Void, Never>?
|
||||
|
||||
public func subscribe() {
|
||||
guard !isSubscribed else { return }
|
||||
isSubscribed = true
|
||||
|
||||
let handler = StatusHandler(self)
|
||||
runningTask = Task { [weak self] in
|
||||
do {
|
||||
try await Task.detached {
|
||||
try LibboxNewStandaloneCommandClient()!.subscribeTailscaleStatus(handler)
|
||||
}.value
|
||||
} catch {
|
||||
guard let self else { return }
|
||||
self.isSubscribed = false
|
||||
self.endpoints = []
|
||||
}
|
||||
self?.runningTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
runningTask?.cancel()
|
||||
runningTask = nil
|
||||
isSubscribed = false
|
||||
endpoints = []
|
||||
}
|
||||
|
||||
public func endpoint(tag: String) -> TailscaleEndpointData? {
|
||||
endpoints.first { $0.endpointTag == tag }
|
||||
}
|
||||
|
||||
private final class StatusHandler: NSObject, LibboxTailscaleStatusHandlerProtocol, @unchecked Sendable {
|
||||
private weak var viewModel: TailscaleStatusViewModel?
|
||||
|
||||
init(_ viewModel: TailscaleStatusViewModel?) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
func onStatusUpdate(_ status: LibboxTailscaleStatusUpdate?) {
|
||||
guard let status else { return }
|
||||
let endpoints = Self.convertUpdate(status)
|
||||
DispatchQueue.main.async { [self] in
|
||||
guard let viewModel, viewModel.isSubscribed else { return }
|
||||
viewModel.endpoints = endpoints
|
||||
}
|
||||
}
|
||||
|
||||
func onError(_ message: String?) {
|
||||
DispatchQueue.main.async { [self] in
|
||||
guard let viewModel, viewModel.isSubscribed else { return }
|
||||
viewModel.isSubscribed = false
|
||||
viewModel.endpoints = []
|
||||
if let message {
|
||||
viewModel.alert = AlertState(errorMessage: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func convertUpdate(_ status: LibboxTailscaleStatusUpdate) -> [TailscaleEndpointData] {
|
||||
var endpoints: [TailscaleEndpointData] = []
|
||||
if let iterator = status.endpoints() {
|
||||
while iterator.hasNext() {
|
||||
if let endpoint = iterator.next() {
|
||||
endpoints.append(convertEndpoint(endpoint))
|
||||
}
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
private static func convertEndpoint(_ endpoint: LibboxTailscaleEndpointStatus) -> TailscaleEndpointData {
|
||||
var userGroups: [TailscaleUserGroupData] = []
|
||||
if let groupIterator = endpoint.userGroups() {
|
||||
while groupIterator.hasNext() {
|
||||
if let group = groupIterator.next() {
|
||||
userGroups.append(convertUserGroup(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
return TailscaleEndpointData(
|
||||
id: endpoint.endpointTag,
|
||||
endpointTag: endpoint.endpointTag,
|
||||
backendState: endpoint.backendState,
|
||||
authURL: endpoint.authURL,
|
||||
networkName: endpoint.networkName,
|
||||
magicDNSSuffix: endpoint.magicDNSSuffix,
|
||||
selfPeer: endpoint.self_ != nil ? convertPeer(endpoint.self_!) : nil,
|
||||
userGroups: userGroups
|
||||
)
|
||||
}
|
||||
|
||||
private static func convertUserGroup(_ group: LibboxTailscaleUserGroup) -> TailscaleUserGroupData {
|
||||
var peers: [TailscalePeerData] = []
|
||||
if let peerIterator = group.peers() {
|
||||
while peerIterator.hasNext() {
|
||||
if let peer = peerIterator.next() {
|
||||
peers.append(convertPeer(peer))
|
||||
}
|
||||
}
|
||||
}
|
||||
return TailscaleUserGroupData(
|
||||
id: group.userID,
|
||||
loginName: group.loginName,
|
||||
displayName: group.displayName,
|
||||
profilePicURL: group.profilePicURL,
|
||||
peers: peers
|
||||
)
|
||||
}
|
||||
|
||||
private static func convertPeer(_ peer: LibboxTailscalePeer) -> TailscalePeerData {
|
||||
var ips: [String] = []
|
||||
if let ipIterator = peer.tailscaleIPs() {
|
||||
while ipIterator.hasNext() {
|
||||
ips.append(ipIterator.next())
|
||||
}
|
||||
}
|
||||
return TailscalePeerData(
|
||||
id: peer.dnsName.isEmpty ? peer.hostName : peer.dnsName,
|
||||
hostName: peer.hostName,
|
||||
dnsName: peer.dnsName,
|
||||
os: peer.os,
|
||||
tailscaleIPs: ips,
|
||||
online: peer.online,
|
||||
exitNode: peer.exitNode,
|
||||
exitNodeOption: peer.exitNodeOption,
|
||||
active: peer.active,
|
||||
rxBytes: peer.rxBytes,
|
||||
txBytes: peer.txBytes,
|
||||
keyExpiry: peer.keyExpiry
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import Library
|
||||
import NetworkExtension
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public struct ToolsView: View {
|
||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||
@StateObject private var viewModel = SettingViewModel()
|
||||
@StateObject private var tailscaleViewModel = TailscaleStatusViewModel()
|
||||
#if os(iOS)
|
||||
@State private var showCrashReportList = false
|
||||
@State private var showOOMReportList = false
|
||||
@@ -14,6 +16,35 @@ public struct ToolsView: View {
|
||||
|
||||
public var body: some View {
|
||||
FormView {
|
||||
if !tailscaleViewModel.endpoints.isEmpty {
|
||||
Section("Endpoints") {
|
||||
ForEach(tailscaleViewModel.endpoints) { endpoint in
|
||||
FormNavigationLink {
|
||||
TailscaleEndpointView(viewModel: tailscaleViewModel, endpointTag: endpoint.endpointTag)
|
||||
} label: {
|
||||
if tailscaleViewModel.endpoints.count == 1 {
|
||||
Label("Tailscale", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
} else {
|
||||
Label("Tailscale: \(endpoint.endpointTag)", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Network") {
|
||||
FormNavigationLink {
|
||||
NetworkQualityView()
|
||||
} 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 +81,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 +100,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)
|
||||
}
|
||||
}
|
||||
@@ -93,5 +124,42 @@ public struct ToolsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(TailscaleStatusObserver(profile: environments.extensionProfile, viewModel: tailscaleViewModel))
|
||||
.alert($tailscaleViewModel.alert)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TailscaleStatusObserver: ViewModifier {
|
||||
var profile: ExtensionProfile?
|
||||
var viewModel: TailscaleStatusViewModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let profile {
|
||||
content
|
||||
.modifier(ActiveObserver(profile: profile, viewModel: viewModel))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActiveObserver: ViewModifier {
|
||||
@ObservedObject var profile: ExtensionProfile
|
||||
var viewModel: TailscaleStatusViewModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onChangeCompat(of: profile.status) { status in
|
||||
if status.isConnectedStrict {
|
||||
viewModel.subscribe()
|
||||
} else {
|
||||
viewModel.cancel()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if profile.status.isConnectedStrict {
|
||||
viewModel.subscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,11 @@ extension RootHelperService: RootHelperProtocol {
|
||||
reply(result, nil)
|
||||
}
|
||||
|
||||
func promoteOOMDraft(reply: @escaping (NSError?) -> Void) {
|
||||
LibboxPromoteOOMDraftAt(WorkingDirectoryManager.extensionWorkingDirectoryPath)
|
||||
reply(nil)
|
||||
}
|
||||
|
||||
func triggerGoCrash(reply: @escaping (NSError?) -> Void) {
|
||||
reply(nil)
|
||||
LibboxTriggerGoPanic()
|
||||
|
||||
@@ -2,9 +2,11 @@ import Foundation
|
||||
import Libbox
|
||||
import Library
|
||||
|
||||
LibboxPrepareCrashSignalHandlers()
|
||||
NativeCrashReporter.installForCurrentProcess(
|
||||
basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true)
|
||||
)
|
||||
LibboxReinstallCrashSignalHandlers()
|
||||
|
||||
let pendingCrashLogs = RootHelperService.readCrashLogFiles()
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -62,6 +62,9 @@ public class ExtensionProfile: ObservableObject {
|
||||
self.connection = connection
|
||||
self.status = connection.status
|
||||
self.connectedDate = connection.connectedDate
|
||||
if connection.status == .disconnected {
|
||||
Self.schedulePromoteOOMDraft()
|
||||
}
|
||||
#if os(iOS)
|
||||
if #available(iOS 16.0, *) {
|
||||
if connection.status == .connected || connection.status == .disconnected {
|
||||
@@ -73,6 +76,26 @@ public class ExtensionProfile: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func schedulePromoteOOMDraft() {
|
||||
Task.detached {
|
||||
try? await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
guard HelperServiceManager.rootHelperStatus == .enabled else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try RootHelperClient.shared.promoteOOMDraft()
|
||||
} catch {
|
||||
logger.warning("promote OOM draft: \(error.localizedDescription)")
|
||||
}
|
||||
return
|
||||
}
|
||||
#endif
|
||||
LibboxPromoteOOMDraft()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@available(iOS 16.0, *)
|
||||
private static func signalFileProviderChanges() {
|
||||
@@ -291,7 +314,11 @@ public class ExtensionProfile: ObservableObject {
|
||||
if managers.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return ExtensionProfile(managers[0])
|
||||
let profile = ExtensionProfile(managers[0])
|
||||
if profile.status == .disconnected {
|
||||
schedulePromoteOOMDraft()
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
public static func install() async throws {
|
||||
|
||||
@@ -81,6 +81,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
#endif
|
||||
|
||||
override public init() {
|
||||
LibboxPrepareCrashSignalHandlers()
|
||||
#if os(macOS)
|
||||
if Variant.useSystemExtension {
|
||||
NativeCrashReporter.installForCurrentProcess(
|
||||
@@ -93,6 +94,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
#else
|
||||
NativeCrashReporter.installForCurrentProcess()
|
||||
#endif
|
||||
LibboxReinstallCrashSignalHandlers()
|
||||
super.init()
|
||||
}
|
||||
|
||||
@@ -148,7 +150,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
options.tempPath = tempPath
|
||||
|
||||
options.logMaxLines = 3000
|
||||
options.debug = SharedPreferences.inDebug
|
||||
options.debug = Variant.inDebug
|
||||
options.crashReportSource = "NetworkExtension"
|
||||
|
||||
#if os(tvOS)
|
||||
@@ -174,6 +176,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
if let setupError {
|
||||
throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)")
|
||||
}
|
||||
LibboxPromoteOOMDraft()
|
||||
|
||||
var error: NSError?
|
||||
commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void)
|
||||
func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void)
|
||||
func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void)
|
||||
func promoteOOMDraft(reply: @escaping (NSError?) -> Void)
|
||||
func triggerGoCrash(reply: @escaping (NSError?) -> Void)
|
||||
func triggerNativeCrash(reply: @escaping (NSError?) -> Void)
|
||||
}
|
||||
@@ -441,6 +442,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
public func promoteOOMDraft() throws {
|
||||
try performXPCCallVoid("promoteOOMDraft") { proxy, reply in
|
||||
proxy.promoteOOMDraft(reply: reply)
|
||||
}
|
||||
}
|
||||
|
||||
public func triggerGoCrash() throws {
|
||||
try performXPCCallVoid("triggerGoCrash") { proxy, reply in
|
||||
proxy.triggerGoCrash(reply: reply)
|
||||
|
||||
+1376
-435
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@ import UserNotifications
|
||||
|
||||
open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
public func applicationDidFinishLaunching(_: Notification) {
|
||||
LibboxPrepareCrashSignalHandlers()
|
||||
NativeCrashReporter.installForCurrentProcess()
|
||||
LibboxReinstallCrashSignalHandlers()
|
||||
NSLog("Here I stand")
|
||||
let options = LibboxSetupOptions()
|
||||
options.basePath = FilePath.sharedDirectory.relativePath
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe
|
||||
private var reportTransferServer: ReportTransferServer?
|
||||
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
LibboxPrepareCrashSignalHandlers()
|
||||
NativeCrashReporter.installForCurrentProcess()
|
||||
LibboxReinstallCrashSignalHandlers()
|
||||
NSLog("Here I stand")
|
||||
let options = LibboxSetupOptions()
|
||||
options.basePath = FilePath.sharedDirectory.relativePath
|
||||
|
||||
@@ -6,7 +6,9 @@ import UIKit
|
||||
|
||||
class ApplicationDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
LibboxPrepareCrashSignalHandlers()
|
||||
NativeCrashReporter.installForCurrentProcess()
|
||||
LibboxReinstallCrashSignalHandlers()
|
||||
NSLog("Here I stand")
|
||||
let options = LibboxSetupOptions()
|
||||
options.basePath = FilePath.sharedDirectory.relativePath
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -2243,7 +2243,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
|
||||
PRODUCT_NAME = "sing-box";
|
||||
SDKROOT = appletvos;
|
||||
@@ -2277,7 +2277,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
|
||||
PRODUCT_NAME = "sing-box";
|
||||
SDKROOT = appletvos;
|
||||
@@ -2670,7 +2670,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0";
|
||||
OTHER_CODE_SIGN_FLAGS = "--deep";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
|
||||
PRODUCT_NAME = "sing-box";
|
||||
@@ -2712,7 +2712,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0";
|
||||
OTHER_CODE_SIGN_FLAGS = "--deep";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
|
||||
PRODUCT_NAME = "sing-box";
|
||||
@@ -2752,7 +2752,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0";
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
|
||||
PRODUCT_NAME = "sing-box";
|
||||
@@ -2791,7 +2791,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0";
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
|
||||
PRODUCT_NAME = "sing-box";
|
||||
@@ -2933,7 +2933,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0-alpha.17";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system;
|
||||
PRODUCT_NAME = "$(inherited)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -2981,7 +2981,7 @@
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0-alpha.17";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system;
|
||||
PRODUCT_NAME = "$(inherited)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -3024,7 +3024,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0-alpha.17";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone;
|
||||
PRODUCT_NAME = SFM;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -3066,7 +3066,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = "1.13.11";
|
||||
MARKETING_VERSION = "1.14.0-alpha.17";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone;
|
||||
PRODUCT_NAME = SFM;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
Reference in New Issue
Block a user