192 lines
8.4 KiB
Swift
192 lines
8.4 KiB
Swift
#if os(iOS)
|
|
|
|
import Foundation
|
|
import Library
|
|
import Network
|
|
import os
|
|
import UIKit
|
|
|
|
private let logger = Logger(category: "ReportTransferServer")
|
|
|
|
public extension Notification.Name {
|
|
static let reportReceived = Notification.Name("reportReceived")
|
|
}
|
|
|
|
public class ReportTransferServer {
|
|
private var listener: NWListener
|
|
|
|
@available(iOS 16.0, *)
|
|
public init() throws {
|
|
listener = try NWListener(using: .applicationService)
|
|
listener.service = NWListener.Service(applicationService: ReportTransferService.applicationServiceName)
|
|
listener.newConnectionHandler = { connection in
|
|
connection.stateUpdateHandler = { state in
|
|
if state == .ready {
|
|
Task.detached {
|
|
try await Task.sleep(nanoseconds: NSEC_PER_MSEC * 100)
|
|
await ReportTransferConnection(connection).process()
|
|
}
|
|
}
|
|
}
|
|
connection.start(queue: .global())
|
|
}
|
|
}
|
|
|
|
public func start() {
|
|
listener.start(queue: .global())
|
|
}
|
|
|
|
public func cancel() {
|
|
listener.cancel()
|
|
}
|
|
|
|
class ReportTransferConnection {
|
|
private let connection: NWSocket
|
|
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
|
|
|
init(_ connection: NWConnection) {
|
|
self.connection = NWSocket(connection)
|
|
}
|
|
|
|
func process() async {
|
|
beginBackgroundTask()
|
|
defer { endBackgroundTask() }
|
|
|
|
do {
|
|
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)")
|
|
await writeError(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
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: manifest.timestamp)
|
|
let artifactURL = ReportArchive.nextAvailableArtifactURL(in: reportsDir, for: date)
|
|
let stagingURL = nextAvailableStagingArtifactURL(in: reportsDir, for: artifactURL.lastPathComponent)
|
|
try FileManager.default.createDirectory(at: stagingURL, withIntermediateDirectories: true)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
private func writeMetadataWithDeviceOrigin(_ data: Data, to url: URL) throws {
|
|
guard var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
try data.write(to: url, options: .atomic)
|
|
return
|
|
}
|
|
json["deviceOrigin"] = ReportArchive.tvOSDeviceOrigin
|
|
let patched = try JSONSerialization.data(withJSONObject: json)
|
|
try patched.write(to: url, options: .atomic)
|
|
}
|
|
|
|
private func writeError(_ message: String) async {
|
|
try? await connection.write(ReportTransferMessage.encodeError(message))
|
|
}
|
|
|
|
private func beginBackgroundTask() {
|
|
backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in
|
|
logger.warning("report transfer server: background task expiring")
|
|
self?.connection.cancel()
|
|
self?.endBackgroundTask()
|
|
}
|
|
}
|
|
|
|
private func endBackgroundTask() {
|
|
guard backgroundTaskID != .invalid else { return }
|
|
UIApplication.shared.endBackgroundTask(backgroundTaskID)
|
|
backgroundTaskID = .invalid
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|