diff --git a/Adobe Downloader/Commons/NewStructs.swift b/Adobe Downloader/Commons/NewStructs.swift index a41f088..3b3701e 100644 --- a/Adobe Downloader/Commons/NewStructs.swift +++ b/Adobe Downloader/Commons/NewStructs.swift @@ -177,6 +177,7 @@ class Package: Identifiable, ObservableObject, Codable { var downloadSize: Int64 var downloadURL: String var packageVersion: String + var validationURL: String? @Published var downloadedSize: Int64 = 0 { didSet { @@ -225,12 +226,13 @@ class Package: Identifiable, ObservableObject, Codable { } } - init(type: String, fullPackageName: String, downloadSize: Int64, downloadURL: String, packageVersion: String, condition: String = "", isRequired: Bool = false) { + init(type: String, fullPackageName: String, downloadSize: Int64, downloadURL: String, packageVersion: String, condition: String = "", isRequired: Bool = false, validationURL: String? = nil) { self.type = type self.fullPackageName = fullPackageName self.downloadSize = downloadSize self.downloadURL = downloadURL self.packageVersion = packageVersion + self.validationURL = validationURL self.condition = condition self.isRequired = isRequired self.isSelected = isRequired @@ -306,7 +308,7 @@ class Package: Identifiable, ObservableObject, Codable { } enum CodingKeys: String, CodingKey { - case id, type, fullPackageName, downloadSize, downloadURL, packageVersion, condition, isRequired + case id, type, fullPackageName, downloadSize, downloadURL, packageVersion, validationURL, condition, isRequired } func encode(to encoder: Encoder) throws { @@ -317,6 +319,7 @@ class Package: Identifiable, ObservableObject, Codable { try container.encode(downloadSize, forKey: .downloadSize) try container.encode(downloadURL, forKey: .downloadURL) try container.encode(packageVersion, forKey: .packageVersion) + try container.encodeIfPresent(validationURL, forKey: .validationURL) try container.encode(condition, forKey: .condition) try container.encode(isRequired, forKey: .isRequired) } @@ -329,6 +332,7 @@ class Package: Identifiable, ObservableObject, Codable { downloadSize = try container.decode(Int64.self, forKey: .downloadSize) downloadURL = try container.decode(String.self, forKey: .downloadURL) packageVersion = try container.decode(String.self, forKey: .packageVersion) + validationURL = try container.decodeIfPresent(String.self, forKey: .validationURL) condition = try container.decodeIfPresent(String.self, forKey: .condition) ?? "" isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false isSelected = isRequired @@ -338,6 +342,94 @@ class Package: Identifiable, ObservableObject, Codable { } } +/* ========== */ +struct ValidationInfo { + var segmentSize: Int64 + var version: String + var algorithm: String + var segmentCount: Int + var lastSegmentSize: Int64 + var packageHashKey: String + var segments: [SegmentInfo] + + struct SegmentInfo { + var segmentNumber: Int + var hash: String + } + + static func parse(from xmlString: String) -> ValidationInfo? { + guard let data = xmlString.data(using: .utf8) else { return nil } + + let parser = ValidationXMLParser() + let xmlParser = XMLParser(data: data) + xmlParser.delegate = parser + + guard xmlParser.parse() else { return nil } + + return ValidationInfo( + segmentSize: parser.segmentSize, + version: parser.version, + algorithm: parser.algorithm, + segmentCount: parser.segmentCount, + lastSegmentSize: parser.lastSegmentSize, + packageHashKey: parser.packageHashKey, + segments: parser.segments + ) + } +} + +class ValidationXMLParser: NSObject, XMLParserDelegate { + var segmentSize: Int64 = 0 + var version: String = "" + var algorithm: String = "" + var segmentCount: Int = 0 + var lastSegmentSize: Int64 = 0 + var packageHashKey: String = "" + var segments: [ValidationInfo.SegmentInfo] = [] + + private var currentElement: String = "" + private var currentSegmentNumber: Int = 0 + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) { + currentElement = elementName + + if elementName == "segment", let segmentNumber = attributeDict["segmentNumber"], let number = Int(segmentNumber) { + currentSegmentNumber = number + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines) + + switch currentElement { + case "segmentSize": + if let size = Int64(trimmedString) { + segmentSize = size + } + case "version": + version = trimmedString + case "algorithm": + algorithm = trimmedString + case "segmentCount": + if let count = Int(trimmedString) { + segmentCount = count + } + case "lastSegmentSize": + if let size = Int64(trimmedString) { + lastSegmentSize = size + } + case "packageHashKey": + packageHashKey = trimmedString + case "segment": + if !trimmedString.isEmpty { + segments.append(ValidationInfo.SegmentInfo(segmentNumber: currentSegmentNumber, hash: trimmedString)) + } + default: + break + } + } +} + struct NetworkConstants { static let downloadTimeout: TimeInterval = 300 static let maxRetryAttempts = 3 diff --git a/Adobe Downloader/Utils/ChunkedDownloadManager.swift b/Adobe Downloader/Utils/ChunkedDownloadManager.swift index b1ff903..3e62cf4 100644 --- a/Adobe Downloader/Utils/ChunkedDownloadManager.swift +++ b/Adobe Downloader/Utils/ChunkedDownloadManager.swift @@ -5,6 +5,7 @@ // import Foundation +import CryptoKit struct DownloadChunk { let index: Int @@ -14,10 +15,19 @@ struct DownloadChunk { var downloadedSize: Int64 = 0 var isCompleted: Bool = false var isPaused: Bool = false + let expectedHash: String? var progress: Double { return size > 0 ? Double(downloadedSize) / Double(size) : 0.0 } + + init(index: Int, startOffset: Int64, endOffset: Int64, size: Int64, expectedHash: String? = nil) { + self.index = index + self.startOffset = startOffset + self.endOffset = endOffset + self.size = size + self.expectedHash = expectedHash + } } struct ChunkedDownloadState: Codable { @@ -28,6 +38,7 @@ struct ChunkedDownloadState: Codable { let totalDownloadedSize: Int64 let isCompleted: Bool let destinationURL: String + let validationInfo: ValidationInfo? struct ChunkInfo: Codable { let index: Int @@ -37,6 +48,53 @@ struct ChunkedDownloadState: Codable { let downloadedSize: Int64 let isCompleted: Bool let isPaused: Bool + let expectedHash: String? + } +} + +extension ValidationInfo: Codable { + enum CodingKeys: String, CodingKey { + case segmentSize, version, algorithm, segmentCount, lastSegmentSize, packageHashKey, segments + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(segmentSize, forKey: .segmentSize) + try container.encode(version, forKey: .version) + try container.encode(algorithm, forKey: .algorithm) + try container.encode(segmentCount, forKey: .segmentCount) + try container.encode(lastSegmentSize, forKey: .lastSegmentSize) + try container.encode(packageHashKey, forKey: .packageHashKey) + try container.encode(segments, forKey: .segments) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + segmentSize = try container.decode(Int64.self, forKey: .segmentSize) + version = try container.decode(String.self, forKey: .version) + algorithm = try container.decode(String.self, forKey: .algorithm) + segmentCount = try container.decode(Int.self, forKey: .segmentCount) + lastSegmentSize = try container.decode(Int64.self, forKey: .lastSegmentSize) + packageHashKey = try container.decode(String.self, forKey: .packageHashKey) + segments = try container.decode([SegmentInfo].self, forKey: .segments) + } +} + +extension ValidationInfo.SegmentInfo: Codable { + enum CodingKeys: String, CodingKey { + case segmentNumber, hash + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(segmentNumber, forKey: .segmentNumber) + try container.encode(hash, forKey: .hash) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + segmentNumber = try container.decode(Int.self, forKey: .segmentNumber) + hash = try container.decode(String.self, forKey: .hash) } } @@ -85,27 +143,75 @@ class ChunkedDownloadManager { return (acceptsRanges, contentLength, etag) } - func createChunkedDownload(packageIdentifier: String, totalSize: Int64, destinationURL: URL) -> [DownloadChunk] { - let numChunks = Int(ceil(Double(totalSize) / Double(chunkSize))) - var chunks: [DownloadChunk] = [] - - for i in 0.. [DownloadChunk] { + if let validationInfo = validationInfo { + var chunks: [DownloadChunk] = [] - chunks.append(DownloadChunk( - index: i, - startOffset: startOffset, - endOffset: endOffset, - size: size - )) - } + for segment in validationInfo.segments { + let segmentIndex = segment.segmentNumber - 1 + let startOffset = Int64(segmentIndex) * validationInfo.segmentSize + let isLastSegment = segment.segmentNumber == validationInfo.segmentCount + let segmentSize = isLastSegment ? validationInfo.lastSegmentSize : validationInfo.segmentSize + let endOffset = startOffset + segmentSize - 1 + + chunks.append(DownloadChunk( + index: segmentIndex, + startOffset: startOffset, + endOffset: endOffset, + size: segmentSize, + expectedHash: segment.hash + )) + } + + return chunks.sorted { $0.index < $1.index } + } else { + let standardChunkSize: Int64 = 2 * 1024 * 1024 + let numChunks = Int(ceil(Double(totalSize) / Double(standardChunkSize))) + var chunks: [DownloadChunk] = [] + + for i in 0.. Bool { + let hash = Insecure.MD5.hash(data: data) + let hashString = hash.map { String(format: "%02hhx", $0) }.joined() + return hashString.lowercased() == expectedHash.lowercased() + } + + private func validateCompleteChunkFromFile(destinationURL: URL, chunk: DownloadChunk) -> Bool { + guard let expectedHash = chunk.expectedHash else { + return true + } + + do { + let fileHandle = try FileHandle(forReadingFrom: destinationURL) + defer { fileHandle.closeFile() } + + fileHandle.seek(toFileOffset: UInt64(chunk.startOffset)) + let chunkData = fileHandle.readData(ofLength: Int(chunk.size)) + + return validateChunkHash(data: chunkData, expectedHash: expectedHash) + } catch { + return false + } + } + + private func writeDataToFile(data: Data, destinationURL: URL, offset: Int64, totalSize: Int64? = nil) async throws { return try await withCheckedThrowingContinuation { continuation in DispatchQueue.global(qos: .utility).async { do { @@ -114,18 +220,23 @@ class ChunkedDownloadManager { try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } + let fileHandle: FileHandle if !self.fileManager.fileExists(atPath: destinationURL.path) { let success = self.fileManager.createFile(atPath: destinationURL.path, contents: nil) if !success { throw NetworkError.filePermissionDenied(destinationURL.path) } + fileHandle = try FileHandle(forWritingTo: destinationURL) + } else { + fileHandle = try FileHandle(forWritingTo: destinationURL) } - - let fileHandle = try FileHandle(forWritingTo: destinationURL) - defer { fileHandle.closeFile() } + defer { fileHandle.closeFile() } + fileHandle.seek(toFileOffset: UInt64(offset)) fileHandle.write(data) + + fileHandle.synchronizeFile() continuation.resume() } catch { @@ -154,18 +265,28 @@ class ChunkedDownloadManager { } if fileManager.fileExists(atPath: destinationURL.path) { - let existingSize = (try? fileManager.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64) ?? 0 + if let expectedHash = chunk.expectedHash { + if validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) { + modifiedChunk.downloadedSize = chunk.size + modifiedChunk.isCompleted = true + return modifiedChunk + } + modifiedChunk.downloadedSize = 0 + } else { + let existingSize = (try? fileManager.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64) ?? 0 + if existingSize > chunk.endOffset { + if let fileHandle = try? FileHandle(forReadingFrom: destinationURL) { + defer { fileHandle.closeFile() } + fileHandle.seek(toFileOffset: UInt64(chunk.startOffset)) + let data = fileHandle.readData(ofLength: Int(chunk.size)) - if existingSize > chunk.endOffset { - modifiedChunk.downloadedSize = chunk.size - modifiedChunk.isCompleted = true - return modifiedChunk - } - - let chunkActualStartOffset = chunk.startOffset + chunk.downloadedSize - if existingSize > chunkActualStartOffset { - let alreadyDownloaded = min(existingSize - chunkActualStartOffset, chunk.size - chunk.downloadedSize) - modifiedChunk.downloadedSize += alreadyDownloaded + if !data.allSatisfy({ $0 == 0 }) { + modifiedChunk.downloadedSize = chunk.size + modifiedChunk.isCompleted = true + return modifiedChunk + } + } + } } } @@ -201,12 +322,18 @@ class ChunkedDownloadManager { } if httpResponse.statusCode == 206 || httpResponse.statusCode == 200 { - try await writeDataToFile(data: data, destinationURL: destinationURL, offset: actualStartOffset) + try await writeDataToFile(data: data, destinationURL: destinationURL, offset: actualStartOffset, totalSize: nil) modifiedChunk.downloadedSize += Int64(data.count) if modifiedChunk.downloadedSize >= chunk.size { modifiedChunk.isCompleted = true + + if let expectedHash = chunk.expectedHash { + if !validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) { + throw NetworkError.invalidData("分片哈希校验失败: \(chunk.index)") + } + } } progressHandler?(modifiedChunk.downloadedSize, chunk.size) @@ -241,7 +368,7 @@ class ChunkedDownloadManager { clearChunkedDownloadState(packageIdentifier: packageIdentifier) } - func saveChunkedDownloadState(packageIdentifier: String, chunks: [DownloadChunk], totalSize: Int64, destinationURL: URL) { + func saveChunkedDownloadState(packageIdentifier: String, chunks: [DownloadChunk], totalSize: Int64, destinationURL: URL, validationInfo: ValidationInfo? = nil) { let chunkInfos = chunks.map { chunk in ChunkedDownloadState.ChunkInfo( index: chunk.index, @@ -250,18 +377,20 @@ class ChunkedDownloadManager { size: chunk.size, downloadedSize: chunk.downloadedSize, isCompleted: chunk.isCompleted, - isPaused: chunk.isPaused + isPaused: chunk.isPaused, + expectedHash: chunk.expectedHash ) } let state = ChunkedDownloadState( packageIdentifier: packageIdentifier, totalSize: totalSize, - chunkSize: self.chunkSize, + chunkSize: validationInfo?.segmentSize ?? self.chunkSize, chunks: chunkInfos, totalDownloadedSize: chunks.reduce(0) { $0 + $1.downloadedSize }, isCompleted: chunks.allSatisfy { $0.isCompleted }, - destinationURL: destinationURL.path + destinationURL: destinationURL.path, + validationInfo: validationInfo ) let fileName = "\(packageIdentifier.replacingOccurrences(of: "/", with: "_")).chunkstate" @@ -294,10 +423,16 @@ class ChunkedDownloadManager { startOffset: chunkInfo.startOffset, endOffset: chunkInfo.endOffset, size: chunkInfo.size, - downloadedSize: chunkInfo.downloadedSize, - isCompleted: chunkInfo.isCompleted, - isPaused: chunkInfo.isPaused + expectedHash: chunkInfo.expectedHash ) + }.map { chunk in + var restoredChunk = chunk + if let chunkInfo = state.chunks.first(where: { $0.index == chunk.index }) { + restoredChunk.downloadedSize = chunkInfo.downloadedSize + restoredChunk.isCompleted = chunkInfo.isCompleted + restoredChunk.isPaused = chunkInfo.isPaused + } + return restoredChunk } } @@ -307,11 +442,31 @@ class ChunkedDownloadManager { try? fileManager.removeItem(at: fileURL) } + private func fetchValidationInfo(from validationURL: String) async throws -> ValidationInfo? { + guard let url = URL(string: validationURL) else { + throw NetworkError.invalidData("无效的ValidationURL") + } + + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw NetworkError.httpError((response as? HTTPURLResponse)?.statusCode ?? 0, "获取ValidationInfo失败") + } + + guard let xmlString = String(data: data, encoding: .utf8) else { + throw NetworkError.invalidData("无法解析ValidationInfo XML") + } + + return ValidationInfo.parse(from: xmlString) + } + func downloadFileWithChunks( packageIdentifier: String, url: URL, destinationURL: URL, headers: [String: String] = [:], + validationURL: String? = nil, progressHandler: ((Double, Int64, Int64, Double) -> Void)? = nil, cancellationHandler: (() async -> Bool)? = nil ) async throws { @@ -322,12 +477,22 @@ class ChunkedDownloadManager { throw NetworkError.invalidData("无法获取文件大小") } + var validationInfo: ValidationInfo? = nil + if let validationURL = validationURL { + do { + validationInfo = try await fetchValidationInfo(from: validationURL) + } catch { + print("获取ValidationInfo失败: \(error), 降级到自定义分片") + } + } + var chunks: [DownloadChunk] if let savedState = loadChunkedDownloadState(packageIdentifier: packageIdentifier) { + let currentChunkSize = validationInfo?.segmentSize ?? self.chunkSize if savedState.totalSize == totalSize, savedState.destinationURL == destinationURL.path, savedState.packageIdentifier == packageIdentifier, - savedState.chunkSize == self.chunkSize { + savedState.chunkSize == currentChunkSize { chunks = restoreChunksFromState(savedState).map { chunk in var restoredChunk = chunk restoredChunk.isPaused = false @@ -336,11 +501,12 @@ class ChunkedDownloadManager { } else { clearChunkedDownloadState(packageIdentifier: packageIdentifier) - if supportsRange && totalSize > chunkSize { + if supportsRange && (validationInfo != nil || totalSize > chunkSize) { chunks = createChunkedDownload( packageIdentifier: packageIdentifier, totalSize: totalSize, - destinationURL: destinationURL + destinationURL: destinationURL, + validationInfo: validationInfo ) } else { chunks = [DownloadChunk( @@ -352,11 +518,12 @@ class ChunkedDownloadManager { } } } else { - if supportsRange && totalSize > chunkSize { + if supportsRange && (validationInfo != nil || totalSize > chunkSize) { chunks = createChunkedDownload( packageIdentifier: packageIdentifier, totalSize: totalSize, - destinationURL: destinationURL + destinationURL: destinationURL, + validationInfo: validationInfo ) } else { chunks = [DownloadChunk( @@ -368,141 +535,48 @@ class ChunkedDownloadManager { } } - let incompleteChunks = chunks.filter { !$0.isCompleted && !$0.isPaused } + let incompleteChunks = chunks.filter { chunk in + if chunk.isPaused { + return false + } + + if chunk.isCompleted { + return false + } + + if chunk.downloadedSize >= chunk.size { + return false + } + + if let expectedHash = chunk.expectedHash, + fileManager.fileExists(atPath: destinationURL.path) { + if validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) { + return false + } + } + + return true + } + if incompleteChunks.isEmpty { + clearChunkedDownloadState(packageIdentifier: packageIdentifier) return } - - let chunkConcurrency = min(maxConcurrentChunks, 5) - actor SpeedTracker { - private var lastProgressTime = Date() - private var lastDownloadedSize: Int64 = 0 - - func updateSpeed(currentDownloaded: Int64) -> Double { - let now = Date() - let timeDiff = now.timeIntervalSince(lastProgressTime) - - if timeDiff >= 0.5 { - let sizeDiff = currentDownloaded - lastDownloadedSize - let speed = timeDiff > 0 ? Double(sizeDiff) / timeDiff : 0 - - lastProgressTime = now - lastDownloadedSize = currentDownloaded - - return speed - } - return 0 - } - - func initialize(initialSize: Int64) { - lastDownloadedSize = initialSize - lastProgressTime = Date() - } - } + try await downloadChunksSequentially( + chunks: incompleteChunks, + url: url, + destinationURL: destinationURL, + headers: headers, + progressHandler: progressHandler, + cancellationHandler: cancellationHandler, + packageIdentifier: packageIdentifier, + totalSize: totalSize, + validationInfo: validationInfo + ) - actor ChunkStateManager { - private var chunks: [DownloadChunk] - - init(chunks: [DownloadChunk]) { - self.chunks = chunks - } - - func updateChunkProgress(index: Int, downloadedSize: Int64, isCompleted: Bool = false) { - if let chunkIndex = chunks.firstIndex(where: { $0.index == index }) { - chunks[chunkIndex].downloadedSize = downloadedSize - if isCompleted { - chunks[chunkIndex].isCompleted = true - } - } - } - - func updateChunk(_ updatedChunk: DownloadChunk) { - if let index = chunks.firstIndex(where: { $0.index == updatedChunk.index }) { - chunks[index] = updatedChunk - } - } - - func getAllChunks() -> [DownloadChunk] { - return chunks - } - - func getTotalDownloadedSize() -> Int64 { - return chunks.reduce(Int64(0)) { $0 + $1.downloadedSize } - } - } - - let speedTracker = SpeedTracker() - await speedTracker.initialize(initialSize: chunks.reduce(Int64(0)) { $0 + $1.downloadedSize }) - - let chunkStateManager = ChunkStateManager(chunks: chunks) - - try await withThrowingTaskGroup(of: DownloadChunk.self) { group in - let semaphore = AsyncSemaphore(value: chunkConcurrency) - - let initialChunks = await chunkStateManager.getAllChunks() - saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: initialChunks, totalSize: totalSize, destinationURL: destinationURL) - - for chunk in incompleteChunks { - if let cancellationHandler = cancellationHandler { - if await cancellationHandler() { - throw NetworkError.cancelled - } - } - - group.addTask { - await semaphore.wait() - defer { - Task { - await semaphore.signal() - } - } - - if let cancellationHandler = cancellationHandler { - if await cancellationHandler() { - throw NetworkError.cancelled - } - } - - return try await self.downloadChunkToFile( - chunk: chunk, - url: url, - destinationURL: destinationURL, - headers: headers, - progressHandler: { downloaded, total in - Task { - await chunkStateManager.updateChunkProgress( - index: chunk.index, - downloadedSize: downloaded, - isCompleted: downloaded >= total - ) - - let currentTotalDownloaded = await chunkStateManager.getTotalDownloadedSize() - let progress = Double(currentTotalDownloaded) / Double(totalSize) - - let speed = await speedTracker.updateSpeed(currentDownloaded: currentTotalDownloaded) - if speed > 0 { - progressHandler?(progress, currentTotalDownloaded, totalSize, speed) - } - } - }, - cancellationHandler: cancellationHandler - ) - } - } - - do { - for try await completedChunk in group { - await chunkStateManager.updateChunk(completedChunk) - - let currentChunks = await chunkStateManager.getAllChunks() - saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: currentChunks, totalSize: totalSize, destinationURL: destinationURL) - } - } catch { - let currentChunks = await chunkStateManager.getAllChunks() - saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: currentChunks, totalSize: totalSize, destinationURL: destinationURL) - throw error - } + if let validationInfo = validationInfo { + try await validateCompleteFile(destinationURL: destinationURL, validationInfo: validationInfo, totalSize: totalSize) } clearChunkedDownloadState(packageIdentifier: packageIdentifier) @@ -520,4 +594,196 @@ class ChunkedDownloadManager { try await downloadTask.value } + + private func downloadChunksSequentially( + chunks: [DownloadChunk], + url: URL, + destinationURL: URL, + headers: [String: String], + progressHandler: ((Double, Int64, Int64, Double) -> Void)?, + cancellationHandler: (() async -> Bool)?, + packageIdentifier: String, + totalSize: Int64, + validationInfo: ValidationInfo? + ) async throws { + let sortedChunks = chunks.sorted { $0.index < $1.index } + + actor SpeedTracker { + private var lastProgressTime = Date() + private var lastDownloadedSize: Int64 = 0 + + func updateSpeed(currentDownloaded: Int64) -> Double { + let now = Date() + let timeDiff = now.timeIntervalSince(lastProgressTime) + + if timeDiff >= 0.5 { + let sizeDiff = currentDownloaded - lastDownloadedSize + let speed = timeDiff > 0 ? Double(sizeDiff) / timeDiff : 0 + + lastProgressTime = now + lastDownloadedSize = currentDownloaded + + return speed + } + return 0 + } + + func initialize(initialSize: Int64) { + lastDownloadedSize = initialSize + lastProgressTime = Date() + } + } + + actor ChunkStateManager { + private var chunks: [DownloadChunk] + + init(chunks: [DownloadChunk]) { + self.chunks = chunks + } + + func updateChunkProgress(index: Int, downloadedSize: Int64, isCompleted: Bool = false) { + if let chunkIndex = chunks.firstIndex(where: { $0.index == index }) { + chunks[chunkIndex].downloadedSize = downloadedSize + if isCompleted { + chunks[chunkIndex].isCompleted = true + } + } + } + + func updateChunk(_ updatedChunk: DownloadChunk) { + if let index = chunks.firstIndex(where: { $0.index == updatedChunk.index }) { + chunks[index] = updatedChunk + } + } + + func getAllChunks() -> [DownloadChunk] { + return chunks + } + + func getTotalDownloadedSize() -> Int64 { + return chunks.reduce(Int64(0)) { $0 + $1.downloadedSize } + } + } + + let speedTracker = SpeedTracker() + await speedTracker.initialize(initialSize: chunks.reduce(Int64(0)) { $0 + $1.downloadedSize }) + + let chunkStateManager = ChunkStateManager(chunks: chunks) + + let initialChunks = await chunkStateManager.getAllChunks() + saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: initialChunks, totalSize: totalSize, destinationURL: destinationURL, validationInfo: validationInfo) + + for chunk in sortedChunks { + if let cancellationHandler = cancellationHandler { + if await cancellationHandler() { + throw NetworkError.cancelled + } + } + + let completedChunk = try await downloadChunkToFile( + chunk: chunk, + url: url, + destinationURL: destinationURL, + headers: headers, + progressHandler: { downloaded, total in + Task { + await chunkStateManager.updateChunkProgress( + index: chunk.index, + downloadedSize: downloaded, + isCompleted: downloaded >= total + ) + + let currentTotalDownloaded = await chunkStateManager.getTotalDownloadedSize() + let progress = Double(currentTotalDownloaded) / Double(totalSize) + + let speed = await speedTracker.updateSpeed(currentDownloaded: currentTotalDownloaded) + if speed > 0 { + progressHandler?(progress, currentTotalDownloaded, totalSize, speed) + } + } + }, + cancellationHandler: cancellationHandler + ) + + await chunkStateManager.updateChunk(completedChunk) + + let currentChunks = await chunkStateManager.getAllChunks() + saveChunkedDownloadState(packageIdentifier: packageIdentifier, chunks: currentChunks, totalSize: totalSize, destinationURL: destinationURL, validationInfo: validationInfo) + } + } + + private func validateCompleteFile(destinationURL: URL, validationInfo: ValidationInfo, totalSize: Int64) async throws { + do { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: destinationURL.path) + if let fileSize = fileAttributes[.size] as? Int64, fileSize != totalSize { + throw NetworkError.invalidData("文件大小不匹配: 期望\(totalSize), 实际\(fileSize)") + } + } catch { + throw NetworkError.invalidData("无法获取文件大小: \(error.localizedDescription)") + } + + let fileHandle = try FileHandle(forReadingFrom: destinationURL) + defer { fileHandle.closeFile() } + + for segment in validationInfo.segments { + let segmentIndex = segment.segmentNumber - 1 + let startOffset = Int64(segmentIndex) * validationInfo.segmentSize + let isLastSegment = segment.segmentNumber == validationInfo.segmentCount + let segmentSize = isLastSegment ? validationInfo.lastSegmentSize : validationInfo.segmentSize + + fileHandle.seek(toFileOffset: UInt64(startOffset)) + let segmentData = fileHandle.readData(ofLength: Int(segmentSize)) + + if segmentData.count != Int(segmentSize) { + throw NetworkError.invalidData("分片\(segment.segmentNumber)大小不正确: 期望\(segmentSize), 实际\(segmentData.count)") + } + + if !validateChunkHash(data: segmentData, expectedHash: segment.hash) { + throw NetworkError.invalidData("分片\(segment.segmentNumber)哈希校验失败") + } + } + } + + private func ensureFilePreallocated(destinationURL: URL, totalSize: Int64) async throws { + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .utility).async { + do { + let directory = destinationURL.deletingLastPathComponent() + if !self.fileManager.fileExists(atPath: directory.path) { + try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + + if self.fileManager.fileExists(atPath: destinationURL.path) { + if let attributes = try? self.fileManager.attributesOfItem(atPath: destinationURL.path), + let existingSize = attributes[.size] as? Int64 { + if existingSize == totalSize { + continuation.resume() + return + } else if existingSize > totalSize { + try? self.fileManager.removeItem(at: destinationURL) + } + } + } + + let success = self.fileManager.createFile(atPath: destinationURL.path, contents: nil) + if !success { + throw NetworkError.filePermissionDenied(destinationURL.path) + } + + let fileHandle = try FileHandle(forWritingTo: destinationURL) + defer { fileHandle.closeFile() } + + if ftruncate(fileHandle.fileDescriptor, off_t(totalSize)) != 0 { + fileHandle.seek(toFileOffset: UInt64(totalSize - 1)) + fileHandle.write(Data([0])) + } + + fileHandle.synchronizeFile() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } } diff --git a/Adobe Downloader/Utils/NewDownloadUtils.swift b/Adobe Downloader/Utils/NewDownloadUtils.swift index 1526c37..10e7851 100644 --- a/Adobe Downloader/Utils/NewDownloadUtils.swift +++ b/Adobe Downloader/Utils/NewDownloadUtils.swift @@ -843,6 +843,7 @@ class NewDownloadUtils { url: url, destinationURL: destinationURL, headers: NetworkConstants.downloadHeaders, + validationURL: package.validationURL, progressHandler: { progress, downloadedSize, totalSize, speed in Task { await MainActor.run { diff --git a/Adobe Downloader/Views/AboutView.swift b/Adobe Downloader/Views/AboutView.swift index 231effb..913116c 100644 --- a/Adobe Downloader/Views/AboutView.swift +++ b/Adobe Downloader/Views/AboutView.swift @@ -215,14 +215,6 @@ final class GeneralSettingsViewModel: ObservableObject { objectWillChange.send() } } - - var chunkSizeMB: Int { - get { StorageData.shared.chunkSizeMB } - set { - StorageData.shared.chunkSizeMB = newValue - objectWillChange.send() - } - } @Published var automaticallyChecksForUpdates: Bool @Published var automaticallyDownloadsUpdates: Bool @@ -487,11 +479,6 @@ struct DownloadSettingsView: View { ConcurrentDownloadsSettingRow(viewModel: viewModel) .fixedSize(horizontal: false, vertical: true) - - Divider() - - ChunkSizeSettingRow(viewModel: viewModel) - .fixedSize(horizontal: false, vertical: true) } } } @@ -1354,64 +1341,3 @@ struct ConcurrentDownloadsSettingRow: View { } } -struct ChunkSizeSettingRow: View { - @ObservedObject var viewModel: GeneralSettingsViewModel - - var body: some View { - HStack(spacing: 10) { - Text("分片大小") - .font(.system(size: 14)) - .padding(.leading, 5) - - Spacer() - - HStack(spacing: 8) { - Button(action: { - if viewModel.chunkSizeMB > 2 { - viewModel.chunkSizeMB -= 2 - } - }) { - Image(systemName: "minus.circle.fill") - .foregroundColor(viewModel.chunkSizeMB > 2 ? .blue : .gray) - .font(.system(size: 16)) - } - .buttonStyle(PlainButtonStyle()) - .disabled(viewModel.chunkSizeMB <= 2) - - Text("\(viewModel.chunkSizeMB) MB") - .font(.system(size: 14, weight: .medium)) - .frame(minWidth: 50) - - Button(action: { - if viewModel.chunkSizeMB < 20 { - viewModel.chunkSizeMB += 2 - } - }) { - Image(systemName: "plus.circle.fill") - .foregroundColor(viewModel.chunkSizeMB < 20 ? .blue : .gray) - .font(.system(size: 16)) - } - .buttonStyle(PlainButtonStyle()) - .disabled(viewModel.chunkSizeMB >= 20) - } - .padding(.vertical, 3) - .padding(.horizontal, 8) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(5) - .padding(.trailing, 5) - - HStack(spacing: 5) { - Image(systemName: "square.and.arrow.down") - .foregroundColor(.secondary) - .font(.system(size: 12)) - Text("2-20 MB") - .foregroundColor(.secondary) - .font(.system(size: 12)) - } - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color.secondary.opacity(0.05)) - .cornerRadius(4) - } - } -}