Compare commits

...
11 Commits
Author SHA1 Message Date
n3t1zen 3964538f4b Merge remote-tracking branch 'upstream/dev' into dev 2026-05-04 17:40:29 +08:00
世界 e21ab46461 Bump version 1.14.0-alpha.16 2026-04-23 08:33:36 +08:00
世界 82a42ec40a Make start button more conspicuous on iOS 2026-04-23 08:33:35 +08:00
世界 3ed2341355 Improve oom-killer 2026-04-23 08:12:53 +08:00
世界 7a57cfeb88 Minor fixes 2026-04-23 08:12:53 +08:00
世界 dd5bb31c3f Fix tvOS report export 2026-04-23 08:12:53 +08:00
世界 8480bc3bb6 Fix deprecated check error 2026-04-23 08:12:53 +08:00
世界 d833ab870f tools: Tailscale status 2026-04-23 08:12:53 +08:00
世界 22e1941e5e Fix crash handler 2026-04-23 08:12:53 +08:00
世界 cd48f1abf6 tools: Network Quality and STUN 2026-04-23 08:12:52 +08:00
世界 26c2ae540b Add support for MAC and hostname rule items 2026-03-06 08:49:53 +08:00
45 changed files with 3156 additions and 565 deletions
@@ -81,6 +81,14 @@ public final class NWSocket {
try await sendAndAwait(content: LibboxEncodeChunkedMessage(data), timeout: timeout, phase: "write") 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?) { public func send(_ data: Data?) {
guard let data else { guard let data else {
return return
+14 -11
View File
@@ -21,25 +21,27 @@ public enum ReportTransferMessageType: UInt8 {
case ack = 3 case ack = 3
} }
public struct ReportTransferPayload: Codable { public struct ReportTransferManifest: Codable {
public var reportType: ReportType public var reportType: ReportType
public var timestamp: TimeInterval 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.reportType = reportType
self.timestamp = timestamp self.timestamp = timestamp
self.totalBytes = totalBytes
self.files = files self.files = files
} }
} }
public struct ReportTransferFile: Codable { public struct ReportTransferManifestFile: Codable {
public var name: String 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.name = name
self.data = data self.size = size
} }
} }
@@ -53,12 +55,13 @@ public struct ReportTransferError: LocalizedError {
public enum ReportTransferService { public enum ReportTransferService {
public static let applicationServiceName = "sing-box:report-transfer" public static let applicationServiceName = "sing-box:report-transfer"
public static let fileChunkSize = 64 * 1024
} }
public enum ReportTransferMessage { 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]) var data = Data([ReportTransferMessageType.report.rawValue])
try data.append(BinaryEncoder().encode(payload)) try data.append(BinaryEncoder().encode(manifest))
return data return data
} }
@@ -81,8 +84,8 @@ public enum ReportTransferMessage {
return ReportTransferMessageType(rawValue: data[0]) return ReportTransferMessageType(rawValue: data[0])
} }
public static func decodeReport(_ data: Data) throws -> ReportTransferPayload { public static func decodeReport(_ data: Data) throws -> ReportTransferManifest {
try BinaryDecoder().decode(ReportTransferPayload.self, from: data.dropFirst()) try BinaryDecoder().decode(ReportTransferManifest.self, from: data.dropFirst())
} }
public static func decodeError(_ data: Data) -> String { public static func decodeError(_ data: Data) -> String {
@@ -52,37 +52,25 @@
beginBackgroundTask() beginBackgroundTask()
defer { endBackgroundTask() } defer { endBackgroundTask() }
var receivedCount = 0
var lastReportType: ReportType?
do { do {
while true { let message = try await connection.read()
let message = try await connection.read() guard let type = ReportTransferMessage.decodeType(message) else {
guard let type = ReportTransferMessage.decodeType(message) else { throw ReportTransferError("Invalid report transfer message")
continue }
} switch type {
switch type { case .report:
case .report: let manifest = try ReportTransferMessage.decodeReport(message)
let payload = try ReportTransferMessage.decodeReport(message) try await importReport(manifest)
try importReport(payload) logger.info("report transfer server: received report")
lastReportType = payload.reportType await MainActor.run {
receivedCount += 1 NotificationCenter.default.post(name: .reportReceived, object: manifest.reportType)
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
} }
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 { } catch {
logger.warning("report transfer server: \(error.localizedDescription)") logger.warning("report transfer server: \(error.localizedDescription)")
@@ -90,21 +78,83 @@
} }
} }
private func importReport(_ payload: ReportTransferPayload) throws { private func importReport(_ manifest: ReportTransferManifest) async throws {
let reportsDir = FilePath.workingDirectory.appendingPathComponent(payload.reportType.directoryName, isDirectory: true) 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) 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) 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 { do {
let fileURL = artifactURL.appendingPathComponent(file.name) var receivedBytes: UInt64 = 0
if file.name == ReportArchive.metadataFileName { for file in manifest.files {
try writeMetadataWithDeviceOrigin(file.data, to: fileURL) let fileURL = stagingURL.appendingPathComponent(file.name)
} else { FileManager.default.createFile(atPath: fileURL.path, contents: nil)
try file.data.write(to: fileURL, options: .atomic) 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() let disableWarnings = await SharedPreferences.disableDeprecatedWarnings.get()
guard !disableWarnings else { return } guard !disableWarnings else { return }
do { guard let reports = try? LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() else { return }
let reports = try LibboxNewStandaloneCommandClient()!.getDeprecatedNotes() if reports.hasNext() {
if reports.hasNext() {
await MainActor.run {
showNextDeprecatedNote(reports)
}
}
} catch {
await MainActor.run { await MainActor.run {
alert = AlertState(action: "check deprecated notes", error: error) showNextDeprecatedNote(reports)
} }
} }
} }
@@ -146,6 +146,33 @@ public extension View {
} }
#endif #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 { public extension View {
func cardStyle() -> some View { func cardStyle() -> some View {
modifier(CardStyleModifier()) modifier(CardStyleModifier())
@@ -32,7 +32,7 @@ public struct ConnectionView: View {
HStack { HStack {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack(alignment: .center) { HStack(alignment: .center) {
Text("\(connection.network.uppercased()) \(connection.displayDestination)") Text(verbatim: "\(connection.network.uppercased()) \(connection.displayDestination)")
Spacer() Spacer()
if connection.closedAt == nil { if connection.closedAt == nil {
Text("Active").foregroundStyle(.green) Text("Active").foregroundStyle(.green)
@@ -45,8 +45,8 @@ public struct ConnectionView: View {
HStack { HStack {
if let closedAt = connection.closedAt { if let closedAt = connection.closedAt {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.uploadTotal))") Text(verbatim: "\(LibboxFormatBytes(connection.uploadTotal))")
Text("\(LibboxFormatBytes(connection.downloadTotal))") Text(verbatim: "\(LibboxFormatBytes(connection.downloadTotal))")
} }
.font(.caption2) .font(.caption2)
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -60,8 +60,8 @@ public struct ConnectionView: View {
} }
} else { } else {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.upload))/s") Text(verbatim: "\(LibboxFormatBytes(connection.upload))/s")
Text("\(LibboxFormatBytes(connection.download))/s") Text(verbatim: "\(LibboxFormatBytes(connection.download))/s")
} }
.font(.caption2) .font(.caption2)
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -199,11 +199,11 @@ import SwiftUI
Spacer() Spacer()
if !isProfileCard { if !isProfileCard {
Toggle("", isOn: Binding( Toggle(isOn: Binding(
get: { isEnabled }, get: { isEnabled },
set: { _ in onToggle() } set: { _ in onToggle() }
)) )) {}
.labelsHidden() .labelsHidden()
} }
Button { Button {
@@ -13,24 +13,24 @@ public struct DownloadTrafficCard: View {
DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download") DashboardCardHeader(icon: "arrow.down.circle.fill", title: "Download")
if Variant.screenshotMode { if Variant.screenshotMode {
Text("249 MB/s") Text(verbatim: "249 MB/s")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
Text("5.6 GB") Text(verbatim: "5.6 GB")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable { } else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.downlink))/s") Text(verbatim: "\(LibboxFormatBytes(message.downlink))/s")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
Text(LibboxFormatBytes(message.downlinkTotal)) Text(LibboxFormatBytes(message.downlinkTotal))
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("...") Text(verbatim: "...")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
Text("...") Text(verbatim: "...")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -22,7 +22,7 @@ public struct HTTPProxyCard: View {
HStack { HStack {
DashboardCardHeader(icon: "network", title: "System HTTP Proxy") DashboardCardHeader(icon: "network", title: "System HTTP Proxy")
Spacer() Spacer()
Toggle("", isOn: $systemProxyEnabled) Toggle(isOn: $systemProxyEnabled) {}
.labelsHidden() .labelsHidden()
#if os(macOS) #if os(macOS)
.toggleStyle(.switch) .toggleStyle(.switch)
@@ -13,24 +13,24 @@ public struct UploadTrafficCard: View {
DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload") DashboardCardHeader(icon: "arrow.up.circle.fill", title: "Upload")
if Variant.screenshotMode { if Variant.screenshotMode {
Text("38 B/s") Text(verbatim: "38 B/s")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
Text("52 MB") Text(verbatim: "52 MB")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if let message = commandClient.status, message.trafficAvailable { } else if let message = commandClient.status, message.trafficAvailable {
Text("\(LibboxFormatBytes(message.uplink))/s") Text(verbatim: "\(LibboxFormatBytes(message.uplink))/s")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
Text(LibboxFormatBytes(message.uplinkTotal)) Text(LibboxFormatBytes(message.uplinkTotal))
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("...") Text(verbatim: "...")
.font(.title2) .font(.title2)
.fontWeight(.medium) .fontWeight(.medium)
Text("...") Text(verbatim: "...")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -63,6 +63,7 @@ public struct StartStopButton: View {
if !profile.status.isConnected { if !profile.status.isConnected {
Label("Start", systemImage: "play.fill") Label("Start", systemImage: "play.fill")
.padding(.horizontal, 12)
} else { } else {
Label("Stop", systemImage: "stop.fill") Label("Stop", systemImage: "stop.fill")
} }
@@ -41,13 +41,7 @@ public class GroupListViewModel: BaseViewModel {
var items = [OutboundGroupItem]() var items = [OutboundGroupItem]()
let itemIterator = goGroup.getItems()! let itemIterator = goGroup.getItems()!
while itemIterator.hasNext() { while itemIterator.hasNext() {
let goItem = itemIterator.next()! items.append(OutboundGroupItem(itemIterator.next()!))
items.append(OutboundGroupItem(
tag: goItem.tag,
type: goItem.type,
urlTestTime: Date(timeIntervalSince1970: Double(goItem.urlTestTime)),
urlTestDelay: UInt16(goItem.urlTestDelay)
))
} }
var selected = goGroup.selected var selected = goGroup.selected
@@ -18,7 +18,7 @@ public struct GroupView: View {
Text(group.displayType) Text(group.displayType)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("\(group.items.count)") Text(verbatim: "\(group.items.count)")
.font(.subheadline) .font(.subheadline)
.padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)) .padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4))
.background(Color.gray.opacity(0.5)) .background(Color.gray.opacity(0.5))
@@ -117,7 +117,7 @@ public struct EditProfileContentView: View {
private var defaultEditorView: some View { private var defaultEditorView: some View {
#if os(tvOS) #if os(tvOS)
ScrollView { ScrollView {
TextField("", text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) TextField(text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent, axis: .vertical) {}
.lineLimit(1000) .lineLimit(1000)
.font(Font.system(.caption2, design: .monospaced)) .font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
@@ -80,7 +80,7 @@ public struct QRSDisplayView: View {
} label: { } label: {
Image(systemName: "minus") Image(systemName: "minus")
} }
Text("\(Int(sliceSize))") Text(verbatim: "\(Int(sliceSize))")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(minWidth: 50) .frame(minWidth: 50)
Button { Button {
@@ -89,7 +89,7 @@ public struct QRSDisplayView: View {
Image(systemName: "plus") Image(systemName: "plus")
} }
#else #else
Text("\(Int(sliceSize))") Text(verbatim: "\(Int(sliceSize))")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#endif #endif
} }
@@ -195,7 +195,7 @@
.animation(.easeInOut(duration: 0.2), value: progress) .animation(.easeInOut(duration: 0.2), value: progress)
if total > 0 { if total > 0 {
Text("\(min(99, Int(progress * 100)))%") Text(verbatim: "\(min(99, Int(progress * 100)))%")
.font(.system(size: 20, weight: .semibold)) .font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
} }
@@ -199,7 +199,7 @@ public struct AppView: View {
HStack { HStack {
Label("Update", systemImage: "arrow.down.circle") Label("Update", systemImage: "arrow.down.circle")
Spacer() Spacer()
Text("v\(info.versionName)") Text(verbatim: "v\(info.versionName)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -793,7 +793,7 @@ private struct StringListSection: View {
Text(title) Text(title)
Spacer() Spacer()
if !items.isEmpty { if !items.isEmpty {
Text("\(items.count)") Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -806,7 +806,7 @@ private struct StringListSection: View {
Text(title) Text(title)
Spacer() Spacer()
if !items.isEmpty { if !items.isEmpty {
Text("\(items.count)") Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -77,7 +77,7 @@ public struct CrashReportListView: View {
CrashTriggerView() CrashTriggerView()
} }
.toolbar { .toolbar {
if SharedPreferences.inDebug { if Variant.inDebug {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button { Button {
showCrashTrigger = true showCrashTrigger = true
@@ -101,9 +101,9 @@ public struct CrashReportListView: View {
} }
#else #else
.toolbar { .toolbar {
if !manager.reports.isEmpty || SharedPreferences.inDebug { if !manager.reports.isEmpty || Variant.inDebug {
Menu { Menu {
if SharedPreferences.inDebug { if Variant.inDebug {
Menu { Menu {
Menu("Application") { Menu("Application") {
Button("Go Crash") { Button("Go Crash") {
@@ -5,6 +5,12 @@
import Network import Network
import SwiftUI import SwiftUI
private struct StreamedReportFile {
let name: String
let fileURL: URL
let size: UInt64
}
@MainActor @MainActor
public struct ExportReportView: View { public struct ExportReportView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -128,31 +134,23 @@
} }
private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws { private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws {
let fm = FileManager.default let files = try collectFiles(in: reportURL)
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))
}
guard !files.isEmpty else { guard !files.isEmpty else {
throw ReportTransferError("Report is empty") throw ReportTransferError("Report is empty")
} }
let payload = ReportTransferPayload( let totalBytes = files.reduce(0) { $0 + $1.size }
let manifest = ReportTransferManifest(
reportType: reportType, reportType: reportType,
timestamp: reportDate.timeIntervalSince1970, 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()) try await socket.write(ReportTransferMessage.encodeComplete())
let response = try await socket.read() let response = try await socket.read()
@@ -168,6 +166,40 @@
throw NWSocketError.connectionClosed 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 #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
)
}
}
}
+70 -2
View File
@@ -1,10 +1,12 @@
import Library import Library
import NetworkExtension
import SwiftUI import SwiftUI
@MainActor @MainActor
public struct ToolsView: View { public struct ToolsView: View {
@EnvironmentObject private var environments: ExtensionEnvironments @EnvironmentObject private var environments: ExtensionEnvironments
@StateObject private var viewModel = SettingViewModel() @StateObject private var viewModel = SettingViewModel()
@StateObject private var tailscaleViewModel = TailscaleStatusViewModel()
#if os(iOS) #if os(iOS)
@State private var showCrashReportList = false @State private var showCrashReportList = false
@State private var showOOMReportList = false @State private var showOOMReportList = false
@@ -14,6 +16,35 @@ public struct ToolsView: View {
public var body: some View { public var body: some View {
FormView { 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") { Section("Debug") {
#if os(iOS) #if os(iOS)
NavigationLink(isActive: $showCrashReportList) { NavigationLink(isActive: $showCrashReportList) {
@@ -50,7 +81,7 @@ public struct ToolsView: View {
Label("Crash Report", systemImage: "ladybug.fill") Label("Crash Report", systemImage: "ladybug.fill")
Spacer() Spacer()
if environments.crashReportManager.unreadCount > 0 { if environments.crashReportManager.unreadCount > 0 {
Text("\(environments.crashReportManager.unreadCount)") Text(verbatim: "\(environments.crashReportManager.unreadCount)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -69,7 +100,7 @@ public struct ToolsView: View {
Label("OOM Report", systemImage: "memorychip") Label("OOM Report", systemImage: "memorychip")
Spacer() Spacer()
if environments.oomReportManager.unreadCount > 0 { if environments.oomReportManager.unreadCount > 0 {
Text("\(environments.oomReportManager.unreadCount)") Text(verbatim: "\(environments.oomReportManager.unreadCount)")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
@@ -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()
}
}
}
} }
} }
+5
View File
@@ -253,6 +253,11 @@ extension RootHelperService: RootHelperProtocol {
reply(result, nil) reply(result, nil)
} }
func promoteOOMDraft(reply: @escaping (NSError?) -> Void) {
LibboxPromoteOOMDraftAt(WorkingDirectoryManager.extensionWorkingDirectoryPath)
reply(nil)
}
func triggerGoCrash(reply: @escaping (NSError?) -> Void) { func triggerGoCrash(reply: @escaping (NSError?) -> Void) {
reply(nil) reply(nil)
LibboxTriggerGoPanic() LibboxTriggerGoPanic()
+2
View File
@@ -2,9 +2,11 @@ import Foundation
import Libbox import Libbox
import Library import Library
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess( NativeCrashReporter.installForCurrentProcess(
basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true) basePath: URL(fileURLWithPath: WorkingDirectoryManager.helperNativeCrashBasePath, isDirectory: true)
) )
LibboxReinstallCrashSignalHandlers()
let pendingCrashLogs = RootHelperService.readCrashLogFiles() let pendingCrashLogs = RootHelperService.readCrashLogFiles()
+8
View File
@@ -125,6 +125,14 @@ public enum SharedPreferences {
public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false) public static let disableDeprecatedWarnings = Preference<Bool>("disable_deprecated_warnings", defaultValue: false)
// Tools
public static let nqConfigURL = Preference<String>("nq_config_url", defaultValue: "")
public static let nqSerial = Preference<Bool>("nq_serial", defaultValue: false)
public static let nqHttp3 = Preference<Bool>("nq_http3", defaultValue: false)
public static let nqMaxRuntime = Preference<Int>("nq_max_runtime", defaultValue: 30)
public static let stunServer = Preference<String>("stun_server", defaultValue: "")
// Dashboard // Dashboard
public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: []) public static let enabledDashboardCards = Preference<[String]>("enabled_dashboard_cards", defaultValue: [])
+17
View File
@@ -66,6 +66,7 @@ public class CommandClient: ObservableObject {
case log case log
case clashMode case clashMode
case connections case connections
case outbounds
} }
private let connectionTypes: [ConnectionType] private let connectionTypes: [ConnectionType]
@@ -88,6 +89,7 @@ public class CommandClient: ObservableObject {
} }
@Published public var groups: [LibboxOutboundGroup]? @Published public var groups: [LibboxOutboundGroup]?
@Published public var outbounds: [LibboxOutboundGroupItem]?
@Published public var logList: [LogEntry] @Published public var logList: [LogEntry]
@Published public var defaultLogLevel = 0 @Published public var defaultLogLevel = 0
@Published public var selectedLogLevel: Int? @Published public var selectedLogLevel: Int?
@@ -246,6 +248,8 @@ public class CommandClient: ObservableObject {
clientOptions.addCommand(LibboxCommandClashMode) clientOptions.addCommand(LibboxCommandClashMode)
case .connections: case .connections:
clientOptions.addCommand(LibboxCommandConnections) clientOptions.addCommand(LibboxCommandConnections)
case .outbounds:
clientOptions.addCommand(LibboxCommandOutbounds)
} }
} }
clientOptions.statusInterval = Int64(NSEC_PER_SEC) clientOptions.statusInterval = Int64(NSEC_PER_SEC)
@@ -384,6 +388,19 @@ public class CommandClient: ObservableObject {
} }
} }
func writeOutbounds(_ message: (any LibboxOutboundGroupItemIteratorProtocol)?) {
guard let message else { return }
guard isActiveConnection() else { return }
var newOutbounds: [LibboxOutboundGroupItem] = []
while message.hasNext() {
newOutbounds.append(message.next()!)
}
DispatchQueue.main.async { [self] in
guard isActiveConnection() else { return }
commandClient.outbounds = newOutbounds
}
}
func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) { func initializeClashMode(_ modeList: LibboxStringIteratorProtocol?, currentMode: String?) {
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
guard isActiveConnection() else { return } guard isActiveConnection() else { return }
+28 -1
View File
@@ -62,6 +62,9 @@ public class ExtensionProfile: ObservableObject {
self.connection = connection self.connection = connection
self.status = connection.status self.status = connection.status
self.connectedDate = connection.connectedDate self.connectedDate = connection.connectedDate
if connection.status == .disconnected {
Self.schedulePromoteOOMDraft()
}
#if os(iOS) #if os(iOS)
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
if connection.status == .connected || connection.status == .disconnected { 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) #if os(iOS)
@available(iOS 16.0, *) @available(iOS 16.0, *)
private static func signalFileProviderChanges() { private static func signalFileProviderChanges() {
@@ -291,7 +314,11 @@ public class ExtensionProfile: ObservableObject {
if managers.isEmpty { if managers.isEmpty {
return nil return nil
} }
return ExtensionProfile(managers[0]) let profile = ExtensionProfile(managers[0])
if profile.status == .disconnected {
schedulePromoteOOMDraft()
}
return profile
} }
public static func install() async throws { public static func install() async throws {
+4 -1
View File
@@ -81,6 +81,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
#endif #endif
override public init() { override public init() {
LibboxPrepareCrashSignalHandlers()
#if os(macOS) #if os(macOS)
if Variant.useSystemExtension { if Variant.useSystemExtension {
NativeCrashReporter.installForCurrentProcess( NativeCrashReporter.installForCurrentProcess(
@@ -93,6 +94,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
#else #else
NativeCrashReporter.installForCurrentProcess() NativeCrashReporter.installForCurrentProcess()
#endif #endif
LibboxReinstallCrashSignalHandlers()
super.init() super.init()
} }
@@ -148,7 +150,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
options.tempPath = tempPath options.tempPath = tempPath
options.logMaxLines = 3000 options.logMaxLines = 3000
options.debug = SharedPreferences.inDebug options.debug = Variant.inDebug
options.crashReportSource = "NetworkExtension" options.crashReportSource = "NetworkExtension"
#if os(tvOS) #if os(tvOS)
@@ -174,6 +176,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
if let setupError { if let setupError {
throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)") throw ExtensionStartupError("(packet-tunnel) error: setup service: \(setupError.localizedDescription)")
} }
LibboxPromoteOOMDraft()
var error: NSError? var error: NSError?
commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error) commandServer = LibboxNewCommandServer(platformInterface, platformInterface, &error)
+9
View File
@@ -48,6 +48,15 @@ public struct OutboundGroupItem: Codable, Hashable {
self.urlTestDelay = urlTestDelay self.urlTestDelay = urlTestDelay
} }
public init(_ item: LibboxOutboundGroupItem) {
self.init(
tag: item.tag,
type: item.type,
urlTestTime: Date(timeIntervalSince1970: Double(item.urlTestTime)),
urlTestDelay: UInt16(item.urlTestDelay)
)
}
public var displayType: String { public var displayType: String {
LibboxProxyDisplayType(type) LibboxProxyDisplayType(type)
} }
+7
View File
@@ -193,6 +193,7 @@
func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void) func registerMyInterface(name: String, reply: @escaping (NSError?) -> Void)
func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void) func collectAllCrashArtifacts(reply: @escaping (CrashArtifactsResult?, NSError?) -> Void)
func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void) func collectOOMReportArtifacts(reply: @escaping (OOMReportArtifactsResult?, NSError?) -> Void)
func promoteOOMDraft(reply: @escaping (NSError?) -> Void)
func triggerGoCrash(reply: @escaping (NSError?) -> Void) func triggerGoCrash(reply: @escaping (NSError?) -> Void)
func triggerNativeCrash(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 { public func triggerGoCrash() throws {
try performXPCCallVoid("triggerGoCrash") { proxy, reply in try performXPCCallVoid("triggerGoCrash") { proxy, reply in
proxy.triggerGoCrash(reply: reply) proxy.triggerGoCrash(reply: reply)
+1376 -435
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -7,7 +7,9 @@ import UserNotifications
open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { open class ApplicationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
public func applicationDidFinishLaunching(_: Notification) { public func applicationDidFinishLaunching(_: Notification) {
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess() NativeCrashReporter.installForCurrentProcess()
LibboxReinstallCrashSignalHandlers()
NSLog("Here I stand") NSLog("Here I stand")
let options = LibboxSetupOptions() let options = LibboxSetupOptions()
options.basePath = FilePath.sharedDirectory.relativePath options.basePath = FilePath.sharedDirectory.relativePath
+8 -1
View File
@@ -21,7 +21,9 @@ struct EditProfileContentWindow: View {
var body: some View { var body: some View {
Group { Group {
if viewModel.isLoading { if context == nil {
Color.clear
} else if viewModel.isLoading {
ProgressView() ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.task { .task {
@@ -37,8 +39,13 @@ struct EditProfileContentWindow: View {
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
.background(WindowAccessor { window in .background(WindowAccessor { window in
guard let window else { return } guard let window else { return }
if context == nil {
window.close()
return
}
if windowState.window == nil { if windowState.window == nil {
windowState.window = window windowState.window = window
window.isRestorable = false
windowState.onClose = { [weak viewModel] in windowState.onClose = { [weak viewModel] in
viewModel?.reset() viewModel?.reset()
} }
+2
View File
@@ -12,7 +12,9 @@ class ApplicationDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCe
private var reportTransferServer: ReportTransferServer? private var reportTransferServer: ReportTransferServer?
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess() NativeCrashReporter.installForCurrentProcess()
LibboxReinstallCrashSignalHandlers()
NSLog("Here I stand") NSLog("Here I stand")
let options = LibboxSetupOptions() let options = LibboxSetupOptions()
options.basePath = FilePath.sharedDirectory.relativePath options.basePath = FilePath.sharedDirectory.relativePath
+2
View File
@@ -6,7 +6,9 @@ import UIKit
class ApplicationDelegate: NSObject, UIApplicationDelegate { class ApplicationDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
LibboxPrepareCrashSignalHandlers()
NativeCrashReporter.installForCurrentProcess() NativeCrashReporter.installForCurrentProcess()
LibboxReinstallCrashSignalHandlers()
NSLog("Here I stand") NSLog("Here I stand")
let options = LibboxSetupOptions() let options = LibboxSetupOptions()
options.basePath = FilePath.sharedDirectory.relativePath options.basePath = FilePath.sharedDirectory.relativePath
+5 -1
View File
@@ -30,7 +30,11 @@ struct MainView: View {
.tag(page) .tag(page)
.tabItem { .tabItem {
if page == .tools, environments.totalUnreadReportCount > 0 { if page == .tools, environments.totalUnreadReportCount > 0 {
Label("\(page.title) (\(environments.totalUnreadReportCount))", systemImage: "terminal.fill") Label {
Text(verbatim: "\(page.title) (\(environments.totalUnreadReportCount))")
} icon: {
Image(systemName: "terminal.fill")
}
} else { } else {
page.label page.label
} }
+10 -10
View File
@@ -2243,7 +2243,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "1.13.11"; MARKETING_VERSION = "1.14.0";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box"; PRODUCT_NAME = "sing-box";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -2277,7 +2277,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "1.13.11"; MARKETING_VERSION = "1.14.0";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box"; PRODUCT_NAME = "sing-box";
SDKROOT = appletvos; SDKROOT = appletvos;
@@ -2670,7 +2670,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "1.13.11"; MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = "--deep"; OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box"; PRODUCT_NAME = "sing-box";
@@ -2712,7 +2712,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "1.13.11"; MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = "--deep"; OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box"; PRODUCT_NAME = "sing-box";
@@ -2752,7 +2752,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.11"; MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box"; PRODUCT_NAME = "sing-box";
@@ -2791,7 +2791,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = "1.13.11"; MARKETING_VERSION = "1.14.0";
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt; PRODUCT_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt;
PRODUCT_NAME = "sing-box"; PRODUCT_NAME = "sing-box";
@@ -2933,7 +2933,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; 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_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system;
PRODUCT_NAME = "$(inherited)"; PRODUCT_NAME = "$(inherited)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2981,7 +2981,7 @@
"@executable_path/../../../../Frameworks", "@executable_path/../../../../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; 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_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.system;
PRODUCT_NAME = "$(inherited)"; PRODUCT_NAME = "$(inherited)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -3024,7 +3024,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; 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_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone;
PRODUCT_NAME = SFM; PRODUCT_NAME = SFM;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -3066,7 +3066,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 13.0; 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_BUNDLE_IDENTIFIER = io.nekohasekai.sfavt.standalone;
PRODUCT_NAME = SFM; PRODUCT_NAME = SFM;
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";