Fix tvOS report export
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
import Network
|
import Network
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private struct StreamedReportFile: Sendable {
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user