206 lines
7.6 KiB
Swift
206 lines
7.6 KiB
Swift
#if os(tvOS)
|
|
|
|
import DeviceDiscoveryUI
|
|
import Library
|
|
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
|
|
@StateObject private var viewModel = ExportReportViewModel()
|
|
|
|
let reportType: ReportType
|
|
let reportURL: URL
|
|
let reportDate: Date
|
|
|
|
public init(reportType: ReportType, reportURL: URL, reportDate: Date) {
|
|
self.reportType = reportType
|
|
self.reportURL = reportURL
|
|
self.reportDate = reportDate
|
|
}
|
|
|
|
public var body: some View {
|
|
VStack(alignment: .center) {
|
|
if !viewModel.selected {
|
|
Form {
|
|
Section {
|
|
EmptyView()
|
|
} footer: {
|
|
Text("To export this report to your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.")
|
|
}
|
|
|
|
DevicePicker(
|
|
.applicationService(name: ReportTransferService.applicationServiceName)
|
|
) { endpoint in
|
|
viewModel.selected = true
|
|
Task {
|
|
await viewModel.handleEndpoint(endpoint, reportType: reportType, reportURL: reportURL, reportDate: reportDate)
|
|
}
|
|
} label: {
|
|
Text("Select Device")
|
|
} fallback: {
|
|
EmptyView()
|
|
} parameters: {
|
|
.applicationService
|
|
}
|
|
}
|
|
} else if viewModel.exportComplete {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 64))
|
|
.foregroundStyle(.green)
|
|
Text("Export Complete")
|
|
.font(.headline)
|
|
}
|
|
} else {
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
Text("Sending...")
|
|
}
|
|
}
|
|
}
|
|
.focusSection()
|
|
.alert($viewModel.alert)
|
|
.navigationTitle("Export Report")
|
|
.onChange(of: viewModel.exportComplete) { newValue in
|
|
if newValue {
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: NSEC_PER_SEC * 2)
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class ExportReportViewModel: BaseViewModel {
|
|
@Published var selected = false
|
|
@Published var exportComplete = false
|
|
|
|
private var connection: NWConnection?
|
|
private var socket: NWSocket?
|
|
|
|
func reset() {
|
|
cancelConnection()
|
|
selected = false
|
|
}
|
|
|
|
private func cancelConnection() {
|
|
if let connection {
|
|
connection.stateUpdateHandler = nil
|
|
connection.cancel()
|
|
self.connection = nil
|
|
}
|
|
if let socket {
|
|
socket.cancel()
|
|
self.socket = nil
|
|
}
|
|
}
|
|
|
|
func handleEndpoint(_ endpoint: NWEndpoint, reportType: ReportType, reportURL: URL, reportDate: Date) async {
|
|
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
|
|
self.connection = connection
|
|
let socket = NWSocket(connection)
|
|
self.socket = socket
|
|
|
|
connection.stateUpdateHandler = { state in
|
|
switch state {
|
|
case let .failed(error):
|
|
DispatchQueue.main.async { [self] in
|
|
reset()
|
|
alert = AlertState(action: "connect to device", error: error)
|
|
}
|
|
default: break
|
|
}
|
|
}
|
|
connection.start(queue: .global())
|
|
|
|
do {
|
|
try await sendReport(reportType: reportType, reportURL: reportURL, reportDate: reportDate, via: socket)
|
|
cancelConnection()
|
|
exportComplete = true
|
|
} catch {
|
|
alert = AlertState(action: "export report", error: error)
|
|
reset()
|
|
}
|
|
}
|
|
|
|
private nonisolated func sendReport(reportType: ReportType, reportURL: URL, reportDate: Date, via socket: NWSocket) async throws {
|
|
let files = try collectFiles(in: reportURL)
|
|
guard !files.isEmpty else {
|
|
throw ReportTransferError("Report is empty")
|
|
}
|
|
|
|
let totalBytes = files.reduce(0) { $0 + $1.size }
|
|
let manifest = ReportTransferManifest(
|
|
reportType: reportType,
|
|
timestamp: reportDate.timeIntervalSince1970,
|
|
totalBytes: totalBytes,
|
|
files: files.map { ReportTransferManifestFile(name: $0.name, size: $0.size) }
|
|
)
|
|
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()
|
|
guard let responseType = ReportTransferMessage.decodeType(response) else {
|
|
throw NWSocketError.connectionClosed
|
|
}
|
|
switch responseType {
|
|
case .ack:
|
|
break
|
|
case .error:
|
|
throw ReportTransferError(ReportTransferMessage.decodeError(response))
|
|
default:
|
|
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
|