From dd5bb31c3fff643b206774038981e70eac00718d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Mon, 13 Apr 2026 18:37:18 +0800 Subject: [PATCH] Fix tvOS report export --- ApplicationLibrary/Service/NWSocket.swift | 8 ++ .../Service/ReportTransfer.swift | 25 ++-- .../Service/ReportTransferServer.swift | 128 ++++++++++++------ .../Views/Tools/ExportReportView.swift | 68 +++++++--- 4 files changed, 161 insertions(+), 68 deletions(-) diff --git a/ApplicationLibrary/Service/NWSocket.swift b/ApplicationLibrary/Service/NWSocket.swift index 4a98838..b855f80 100644 --- a/ApplicationLibrary/Service/NWSocket.swift +++ b/ApplicationLibrary/Service/NWSocket.swift @@ -81,6 +81,14 @@ public final class NWSocket { try await sendAndAwait(content: LibboxEncodeChunkedMessage(data), timeout: timeout, phase: "write") } + public func readRaw(count: Int, timeout: TimeInterval = 60) async throws -> Data { + try await receiveExactly(count: count, timeout: timeout, phase: "read raw body") + } + + public func writeRaw(_ data: Data, timeout: TimeInterval = 30) async throws { + try await sendAndAwait(content: data, timeout: timeout, phase: "write raw body") + } + public func send(_ data: Data?) { guard let data else { return diff --git a/ApplicationLibrary/Service/ReportTransfer.swift b/ApplicationLibrary/Service/ReportTransfer.swift index 9428d5e..5a128fb 100644 --- a/ApplicationLibrary/Service/ReportTransfer.swift +++ b/ApplicationLibrary/Service/ReportTransfer.swift @@ -21,25 +21,27 @@ public enum ReportTransferMessageType: UInt8 { case ack = 3 } -public struct ReportTransferPayload: Codable { +public struct ReportTransferManifest: Codable { public var reportType: ReportType public var timestamp: TimeInterval - public var files: [ReportTransferFile] + public var totalBytes: UInt64 + public var files: [ReportTransferManifestFile] - public init(reportType: ReportType, timestamp: TimeInterval, files: [ReportTransferFile]) { + public init(reportType: ReportType, timestamp: TimeInterval, totalBytes: UInt64, files: [ReportTransferManifestFile]) { self.reportType = reportType self.timestamp = timestamp + self.totalBytes = totalBytes self.files = files } } -public struct ReportTransferFile: Codable { +public struct ReportTransferManifestFile: Codable { public var name: String - public var data: Data + public var size: UInt64 - public init(name: String, data: Data) { + public init(name: String, size: UInt64) { self.name = name - self.data = data + self.size = size } } @@ -53,12 +55,13 @@ public struct ReportTransferError: LocalizedError { public enum ReportTransferService { public static let applicationServiceName = "sing-box:report-transfer" + public static let fileChunkSize = 64 * 1024 } public enum ReportTransferMessage { - public static func encodeReport(_ payload: ReportTransferPayload) throws -> Data { + public static func encodeReport(_ manifest: ReportTransferManifest) throws -> Data { var data = Data([ReportTransferMessageType.report.rawValue]) - try data.append(BinaryEncoder().encode(payload)) + try data.append(BinaryEncoder().encode(manifest)) return data } @@ -81,8 +84,8 @@ public enum ReportTransferMessage { return ReportTransferMessageType(rawValue: data[0]) } - public static func decodeReport(_ data: Data) throws -> ReportTransferPayload { - try BinaryDecoder().decode(ReportTransferPayload.self, from: data.dropFirst()) + public static func decodeReport(_ data: Data) throws -> ReportTransferManifest { + try BinaryDecoder().decode(ReportTransferManifest.self, from: data.dropFirst()) } public static func decodeError(_ data: Data) -> String { diff --git a/ApplicationLibrary/Service/ReportTransferServer.swift b/ApplicationLibrary/Service/ReportTransferServer.swift index 4e91d31..9732e78 100644 --- a/ApplicationLibrary/Service/ReportTransferServer.swift +++ b/ApplicationLibrary/Service/ReportTransferServer.swift @@ -52,37 +52,25 @@ beginBackgroundTask() defer { endBackgroundTask() } - var receivedCount = 0 - var lastReportType: ReportType? do { - while true { - let message = try await connection.read() - guard let type = ReportTransferMessage.decodeType(message) else { - continue - } - switch type { - case .report: - let payload = try ReportTransferMessage.decodeReport(message) - try importReport(payload) - lastReportType = payload.reportType - receivedCount += 1 - case .complete: - logger.info("report transfer server: received \(receivedCount) report(s)") - if receivedCount > 0 { - let reportType = lastReportType - await MainActor.run { - NotificationCenter.default.post(name: .reportReceived, object: reportType) - } - } - try await connection.write(ReportTransferMessage.encodeAck()) - return - case .error: - let errorMsg = ReportTransferMessage.decodeError(message) - logger.warning("report transfer server: client error: \(errorMsg)") - return - case .ack: - return + let message = try await connection.read() + guard let type = ReportTransferMessage.decodeType(message) else { + throw ReportTransferError("Invalid report transfer message") + } + switch type { + case .report: + let manifest = try ReportTransferMessage.decodeReport(message) + try await importReport(manifest) + logger.info("report transfer server: received report") + await MainActor.run { + NotificationCenter.default.post(name: .reportReceived, object: manifest.reportType) } + try await connection.write(ReportTransferMessage.encodeAck()) + case .error: + let errorMsg = ReportTransferMessage.decodeError(message) + logger.warning("report transfer server: client error: \(errorMsg)") + case .complete, .ack: + throw ReportTransferError("Unexpected report transfer message") } } catch { logger.warning("report transfer server: \(error.localizedDescription)") @@ -90,21 +78,83 @@ } } - private func importReport(_ payload: ReportTransferPayload) throws { - let reportsDir = FilePath.workingDirectory.appendingPathComponent(payload.reportType.directoryName, isDirectory: true) + private func importReport(_ manifest: ReportTransferManifest) async throws { + guard !manifest.files.isEmpty else { + throw ReportTransferError("Report is empty") + } + + let expectedBytes = manifest.files.reduce(0) { $0 + $1.size } + guard expectedBytes == manifest.totalBytes else { + throw ReportTransferError("Invalid report manifest") + } + + let reportsDir = FilePath.workingDirectory.appendingPathComponent(manifest.reportType.directoryName, isDirectory: true) try FileManager.default.createDirectory(at: reportsDir, withIntermediateDirectories: true) - let date = Date(timeIntervalSince1970: payload.timestamp) + let date = Date(timeIntervalSince1970: manifest.timestamp) let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date) - try FileManager.default.createDirectory(at: artifactURL, withIntermediateDirectories: true) + let stagingURL = nextAvailableStagingArtifactURL(in: reportsDir, for: artifactURL.lastPathComponent) + try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: true) - for file in payload.files { - let fileURL = artifactURL.appendingPathComponent(file.name) - if file.name == ReportArchive.metadataFileName { - try writeMetadataWithDeviceOrigin(file.data, to: fileURL) - } else { - try file.data.write(to: fileURL, options: .atomic) + do { + var receivedBytes: UInt64 = 0 + for file in manifest.files { + let fileURL = stagingURL.appendingPathComponent(file.name) + FileManager.default.createFile(atPath: fileURL.path, contents: nil) + do { + let handle = try FileHandle(forWritingTo: fileURL) + defer { try? handle.close() } + + var remaining = file.size + while remaining > 0 { + let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining)) + let data = try await connection.readRaw(count: chunkSize) + try handle.write(contentsOf: data) + remaining -= UInt64(data.count) + receivedBytes += UInt64(data.count) + } + } } + + guard receivedBytes == manifest.totalBytes else { + throw ReportTransferError("Report transfer was incomplete") + } + + let completion = try await connection.read() + guard let completionType = ReportTransferMessage.decodeType(completion) else { + throw ReportTransferError("Invalid report transfer message") + } + switch completionType { + case .complete: + break + case .error: + throw ReportTransferError(ReportTransferMessage.decodeError(completion)) + case .report, .ack: + throw ReportTransferError("Unexpected report transfer message") + } + + let metadataURL = stagingURL.appendingPathComponent(ReportArchive.metadataFileName) + if FileManager.default.fileExists(atPath: metadataURL.path) { + let metadataData = try Data(contentsOf: metadataURL) + try writeMetadataWithDeviceOrigin(metadataData, to: metadataURL) + } + + try FileManager.default.moveItem(at: stagingURL, to: artifactURL) + } catch { + try? FileManager.default.removeItem(at: stagingURL) + throw error + } + } + + private func nextAvailableStagingArtifactURL(in directory: URL, for artifactName: String) -> URL { + var index = 0 + while true { + let suffix = index == 0 ? "" : "-\(index)" + let candidate = directory.appendingPathComponent(".\(artifactName).partial\(suffix)", isDirectory: true) + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + index += 1 } } diff --git a/ApplicationLibrary/Views/Tools/ExportReportView.swift b/ApplicationLibrary/Views/Tools/ExportReportView.swift index ffdc96f..27abef5 100644 --- a/ApplicationLibrary/Views/Tools/ExportReportView.swift +++ b/ApplicationLibrary/Views/Tools/ExportReportView.swift @@ -5,6 +5,12 @@ import Network import SwiftUI + private struct StreamedReportFile: Sendable { + let name: String + let fileURL: URL + let size: UInt64 + } + @MainActor public struct ExportReportView: View { @Environment(\.dismiss) private var dismiss @@ -128,31 +134,23 @@ } private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws { - let fm = FileManager.default - guard let fileURLs = try? fm.contentsOfDirectory( - at: reportURL, - includingPropertiesForKeys: nil, - options: .skipsHiddenFiles - ) else { - throw ReportTransferError("Report is empty") - } - - var files: [ReportTransferFile] = [] - for fileURL in fileURLs { - guard let data = try? Data(contentsOf: fileURL) else { continue } - files.append(ReportTransferFile(name: fileURL.lastPathComponent, data: data)) - } - + let files = try collectFiles(in: reportURL) guard !files.isEmpty else { throw ReportTransferError("Report is empty") } - let payload = ReportTransferPayload( + let totalBytes = files.reduce(0) { $0 + $1.size } + let manifest = ReportTransferManifest( reportType: reportType, timestamp: reportDate.timeIntervalSince1970, - files: files + totalBytes: totalBytes, + files: files.map { ReportTransferManifestFile(name: $0.name, size: $0.size) } ) - try await socket.write(ReportTransferMessage.encodeReport(payload)) + try await socket.write(ReportTransferMessage.encodeReport(manifest)) + + for file in files { + try await streamFile(file, via: socket) + } try await socket.write(ReportTransferMessage.encodeComplete()) let response = try await socket.read() @@ -168,6 +166,40 @@ throw NWSocketError.connectionClosed } } + + private nonisolated func collectFiles(in reportURL: URL) throws -> [StreamedReportFile] { + let fm = FileManager.default + let fileURLs = try fm.contentsOfDirectory( + at: reportURL, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: .skipsHiddenFiles + ) + var files: [StreamedReportFile] = [] + for fileURL in fileURLs.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard values.isRegularFile == true else { + continue + } + let size = UInt64(values.fileSize ?? 0) + files.append(StreamedReportFile(name: fileURL.lastPathComponent, fileURL: fileURL, size: size)) + } + return files + } + + private nonisolated func streamFile(_ file: StreamedReportFile, via socket: NWSocket) async throws { + let handle = try FileHandle(forReadingFrom: file.fileURL) + defer { try? handle.close() } + + var remaining = file.size + while remaining > 0 { + let chunkSize = Int(min(UInt64(ReportTransferService.fileChunkSize), remaining)) + guard let data = try handle.read(upToCount: chunkSize), !data.isEmpty else { + throw ReportTransferError("Failed to read report file") + } + try await socket.writeRaw(data) + remaining -= UInt64(data.count) + } + } } #endif