mirror of
https://github.com/X1a0He/Adobe-Downloader.git
synced 2025-11-25 11:18:53 +08:00
feat: 参考Adobe官方调整分片下载逻辑
This commit is contained in:
@@ -177,6 +177,7 @@ class Package: Identifiable, ObservableObject, Codable {
|
|||||||
var downloadSize: Int64
|
var downloadSize: Int64
|
||||||
var downloadURL: String
|
var downloadURL: String
|
||||||
var packageVersion: String
|
var packageVersion: String
|
||||||
|
var validationURL: String?
|
||||||
|
|
||||||
@Published var downloadedSize: Int64 = 0 {
|
@Published var downloadedSize: Int64 = 0 {
|
||||||
didSet {
|
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.type = type
|
||||||
self.fullPackageName = fullPackageName
|
self.fullPackageName = fullPackageName
|
||||||
self.downloadSize = downloadSize
|
self.downloadSize = downloadSize
|
||||||
self.downloadURL = downloadURL
|
self.downloadURL = downloadURL
|
||||||
self.packageVersion = packageVersion
|
self.packageVersion = packageVersion
|
||||||
|
self.validationURL = validationURL
|
||||||
self.condition = condition
|
self.condition = condition
|
||||||
self.isRequired = isRequired
|
self.isRequired = isRequired
|
||||||
self.isSelected = isRequired
|
self.isSelected = isRequired
|
||||||
@@ -306,7 +308,7 @@ class Package: Identifiable, ObservableObject, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
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 {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -317,6 +319,7 @@ class Package: Identifiable, ObservableObject, Codable {
|
|||||||
try container.encode(downloadSize, forKey: .downloadSize)
|
try container.encode(downloadSize, forKey: .downloadSize)
|
||||||
try container.encode(downloadURL, forKey: .downloadURL)
|
try container.encode(downloadURL, forKey: .downloadURL)
|
||||||
try container.encode(packageVersion, forKey: .packageVersion)
|
try container.encode(packageVersion, forKey: .packageVersion)
|
||||||
|
try container.encodeIfPresent(validationURL, forKey: .validationURL)
|
||||||
try container.encode(condition, forKey: .condition)
|
try container.encode(condition, forKey: .condition)
|
||||||
try container.encode(isRequired, forKey: .isRequired)
|
try container.encode(isRequired, forKey: .isRequired)
|
||||||
}
|
}
|
||||||
@@ -329,6 +332,7 @@ class Package: Identifiable, ObservableObject, Codable {
|
|||||||
downloadSize = try container.decode(Int64.self, forKey: .downloadSize)
|
downloadSize = try container.decode(Int64.self, forKey: .downloadSize)
|
||||||
downloadURL = try container.decode(String.self, forKey: .downloadURL)
|
downloadURL = try container.decode(String.self, forKey: .downloadURL)
|
||||||
packageVersion = try container.decode(String.self, forKey: .packageVersion)
|
packageVersion = try container.decode(String.self, forKey: .packageVersion)
|
||||||
|
validationURL = try container.decodeIfPresent(String.self, forKey: .validationURL)
|
||||||
condition = try container.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
condition = try container.decodeIfPresent(String.self, forKey: .condition) ?? ""
|
||||||
isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false
|
isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false
|
||||||
isSelected = isRequired
|
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 {
|
struct NetworkConstants {
|
||||||
static let downloadTimeout: TimeInterval = 300
|
static let downloadTimeout: TimeInterval = 300
|
||||||
static let maxRetryAttempts = 3
|
static let maxRetryAttempts = 3
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct DownloadChunk {
|
struct DownloadChunk {
|
||||||
let index: Int
|
let index: Int
|
||||||
@@ -14,10 +15,19 @@ struct DownloadChunk {
|
|||||||
var downloadedSize: Int64 = 0
|
var downloadedSize: Int64 = 0
|
||||||
var isCompleted: Bool = false
|
var isCompleted: Bool = false
|
||||||
var isPaused: Bool = false
|
var isPaused: Bool = false
|
||||||
|
let expectedHash: String?
|
||||||
|
|
||||||
var progress: Double {
|
var progress: Double {
|
||||||
return size > 0 ? Double(downloadedSize) / Double(size) : 0.0
|
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 {
|
struct ChunkedDownloadState: Codable {
|
||||||
@@ -28,6 +38,7 @@ struct ChunkedDownloadState: Codable {
|
|||||||
let totalDownloadedSize: Int64
|
let totalDownloadedSize: Int64
|
||||||
let isCompleted: Bool
|
let isCompleted: Bool
|
||||||
let destinationURL: String
|
let destinationURL: String
|
||||||
|
let validationInfo: ValidationInfo?
|
||||||
|
|
||||||
struct ChunkInfo: Codable {
|
struct ChunkInfo: Codable {
|
||||||
let index: Int
|
let index: Int
|
||||||
@@ -37,6 +48,53 @@ struct ChunkedDownloadState: Codable {
|
|||||||
let downloadedSize: Int64
|
let downloadedSize: Int64
|
||||||
let isCompleted: Bool
|
let isCompleted: Bool
|
||||||
let isPaused: 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)
|
return (acceptsRanges, contentLength, etag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createChunkedDownload(packageIdentifier: String, totalSize: Int64, destinationURL: URL) -> [DownloadChunk] {
|
func createChunkedDownload(packageIdentifier: String, totalSize: Int64, destinationURL: URL, validationInfo: ValidationInfo? = nil) -> [DownloadChunk] {
|
||||||
let numChunks = Int(ceil(Double(totalSize) / Double(chunkSize)))
|
if let validationInfo = validationInfo {
|
||||||
var chunks: [DownloadChunk] = []
|
var chunks: [DownloadChunk] = []
|
||||||
|
|
||||||
for i in 0..<numChunks {
|
|
||||||
let startOffset = Int64(i) * chunkSize
|
|
||||||
let endOffset = min(startOffset + chunkSize - 1, totalSize - 1)
|
|
||||||
let size = endOffset - startOffset + 1
|
|
||||||
|
|
||||||
chunks.append(DownloadChunk(
|
for segment in validationInfo.segments {
|
||||||
index: i,
|
let segmentIndex = segment.segmentNumber - 1
|
||||||
startOffset: startOffset,
|
let startOffset = Int64(segmentIndex) * validationInfo.segmentSize
|
||||||
endOffset: endOffset,
|
let isLastSegment = segment.segmentNumber == validationInfo.segmentCount
|
||||||
size: size
|
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..<numChunks {
|
||||||
|
let startOffset = Int64(i) * standardChunkSize
|
||||||
|
let isLastChunk = (i == numChunks - 1)
|
||||||
|
let chunkSize = isLastChunk ? (totalSize - startOffset) : standardChunkSize
|
||||||
|
let endOffset = startOffset + chunkSize - 1
|
||||||
|
|
||||||
|
chunks.append(DownloadChunk(
|
||||||
|
index: i,
|
||||||
|
startOffset: startOffset,
|
||||||
|
endOffset: endOffset,
|
||||||
|
size: chunkSize
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func writeDataToFile(data: Data, destinationURL: URL, offset: Int64) async throws {
|
private func validateChunkHash(data: Data, expectedHash: String) -> 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
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
do {
|
do {
|
||||||
@@ -114,18 +220,23 @@ class ChunkedDownloadManager {
|
|||||||
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fileHandle: FileHandle
|
||||||
if !self.fileManager.fileExists(atPath: destinationURL.path) {
|
if !self.fileManager.fileExists(atPath: destinationURL.path) {
|
||||||
let success = self.fileManager.createFile(atPath: destinationURL.path, contents: nil)
|
let success = self.fileManager.createFile(atPath: destinationURL.path, contents: nil)
|
||||||
if !success {
|
if !success {
|
||||||
throw NetworkError.filePermissionDenied(destinationURL.path)
|
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.seek(toFileOffset: UInt64(offset))
|
||||||
fileHandle.write(data)
|
fileHandle.write(data)
|
||||||
|
|
||||||
|
fileHandle.synchronizeFile()
|
||||||
|
|
||||||
continuation.resume()
|
continuation.resume()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -154,18 +265,28 @@ class ChunkedDownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
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 {
|
if !data.allSatisfy({ $0 == 0 }) {
|
||||||
modifiedChunk.downloadedSize = chunk.size
|
modifiedChunk.downloadedSize = chunk.size
|
||||||
modifiedChunk.isCompleted = true
|
modifiedChunk.isCompleted = true
|
||||||
return modifiedChunk
|
return modifiedChunk
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let chunkActualStartOffset = chunk.startOffset + chunk.downloadedSize
|
}
|
||||||
if existingSize > chunkActualStartOffset {
|
|
||||||
let alreadyDownloaded = min(existingSize - chunkActualStartOffset, chunk.size - chunk.downloadedSize)
|
|
||||||
modifiedChunk.downloadedSize += alreadyDownloaded
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +322,18 @@ class ChunkedDownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if httpResponse.statusCode == 206 || httpResponse.statusCode == 200 {
|
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)
|
modifiedChunk.downloadedSize += Int64(data.count)
|
||||||
|
|
||||||
if modifiedChunk.downloadedSize >= chunk.size {
|
if modifiedChunk.downloadedSize >= chunk.size {
|
||||||
modifiedChunk.isCompleted = true
|
modifiedChunk.isCompleted = true
|
||||||
|
|
||||||
|
if let expectedHash = chunk.expectedHash {
|
||||||
|
if !validateCompleteChunkFromFile(destinationURL: destinationURL, chunk: chunk) {
|
||||||
|
throw NetworkError.invalidData("分片哈希校验失败: \(chunk.index)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
progressHandler?(modifiedChunk.downloadedSize, chunk.size)
|
progressHandler?(modifiedChunk.downloadedSize, chunk.size)
|
||||||
@@ -241,7 +368,7 @@ class ChunkedDownloadManager {
|
|||||||
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
|
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
|
let chunkInfos = chunks.map { chunk in
|
||||||
ChunkedDownloadState.ChunkInfo(
|
ChunkedDownloadState.ChunkInfo(
|
||||||
index: chunk.index,
|
index: chunk.index,
|
||||||
@@ -250,18 +377,20 @@ class ChunkedDownloadManager {
|
|||||||
size: chunk.size,
|
size: chunk.size,
|
||||||
downloadedSize: chunk.downloadedSize,
|
downloadedSize: chunk.downloadedSize,
|
||||||
isCompleted: chunk.isCompleted,
|
isCompleted: chunk.isCompleted,
|
||||||
isPaused: chunk.isPaused
|
isPaused: chunk.isPaused,
|
||||||
|
expectedHash: chunk.expectedHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let state = ChunkedDownloadState(
|
let state = ChunkedDownloadState(
|
||||||
packageIdentifier: packageIdentifier,
|
packageIdentifier: packageIdentifier,
|
||||||
totalSize: totalSize,
|
totalSize: totalSize,
|
||||||
chunkSize: self.chunkSize,
|
chunkSize: validationInfo?.segmentSize ?? self.chunkSize,
|
||||||
chunks: chunkInfos,
|
chunks: chunkInfos,
|
||||||
totalDownloadedSize: chunks.reduce(0) { $0 + $1.downloadedSize },
|
totalDownloadedSize: chunks.reduce(0) { $0 + $1.downloadedSize },
|
||||||
isCompleted: chunks.allSatisfy { $0.isCompleted },
|
isCompleted: chunks.allSatisfy { $0.isCompleted },
|
||||||
destinationURL: destinationURL.path
|
destinationURL: destinationURL.path,
|
||||||
|
validationInfo: validationInfo
|
||||||
)
|
)
|
||||||
|
|
||||||
let fileName = "\(packageIdentifier.replacingOccurrences(of: "/", with: "_")).chunkstate"
|
let fileName = "\(packageIdentifier.replacingOccurrences(of: "/", with: "_")).chunkstate"
|
||||||
@@ -294,10 +423,16 @@ class ChunkedDownloadManager {
|
|||||||
startOffset: chunkInfo.startOffset,
|
startOffset: chunkInfo.startOffset,
|
||||||
endOffset: chunkInfo.endOffset,
|
endOffset: chunkInfo.endOffset,
|
||||||
size: chunkInfo.size,
|
size: chunkInfo.size,
|
||||||
downloadedSize: chunkInfo.downloadedSize,
|
expectedHash: chunkInfo.expectedHash
|
||||||
isCompleted: chunkInfo.isCompleted,
|
|
||||||
isPaused: chunkInfo.isPaused
|
|
||||||
)
|
)
|
||||||
|
}.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)
|
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(
|
func downloadFileWithChunks(
|
||||||
packageIdentifier: String,
|
packageIdentifier: String,
|
||||||
url: URL,
|
url: URL,
|
||||||
destinationURL: URL,
|
destinationURL: URL,
|
||||||
headers: [String: String] = [:],
|
headers: [String: String] = [:],
|
||||||
|
validationURL: String? = nil,
|
||||||
progressHandler: ((Double, Int64, Int64, Double) -> Void)? = nil,
|
progressHandler: ((Double, Int64, Int64, Double) -> Void)? = nil,
|
||||||
cancellationHandler: (() async -> Bool)? = nil
|
cancellationHandler: (() async -> Bool)? = nil
|
||||||
) async throws {
|
) async throws {
|
||||||
@@ -322,12 +477,22 @@ class ChunkedDownloadManager {
|
|||||||
throw NetworkError.invalidData("无法获取文件大小")
|
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]
|
var chunks: [DownloadChunk]
|
||||||
if let savedState = loadChunkedDownloadState(packageIdentifier: packageIdentifier) {
|
if let savedState = loadChunkedDownloadState(packageIdentifier: packageIdentifier) {
|
||||||
|
let currentChunkSize = validationInfo?.segmentSize ?? self.chunkSize
|
||||||
if savedState.totalSize == totalSize,
|
if savedState.totalSize == totalSize,
|
||||||
savedState.destinationURL == destinationURL.path,
|
savedState.destinationURL == destinationURL.path,
|
||||||
savedState.packageIdentifier == packageIdentifier,
|
savedState.packageIdentifier == packageIdentifier,
|
||||||
savedState.chunkSize == self.chunkSize {
|
savedState.chunkSize == currentChunkSize {
|
||||||
chunks = restoreChunksFromState(savedState).map { chunk in
|
chunks = restoreChunksFromState(savedState).map { chunk in
|
||||||
var restoredChunk = chunk
|
var restoredChunk = chunk
|
||||||
restoredChunk.isPaused = false
|
restoredChunk.isPaused = false
|
||||||
@@ -336,11 +501,12 @@ class ChunkedDownloadManager {
|
|||||||
} else {
|
} else {
|
||||||
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
|
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
|
||||||
|
|
||||||
if supportsRange && totalSize > chunkSize {
|
if supportsRange && (validationInfo != nil || totalSize > chunkSize) {
|
||||||
chunks = createChunkedDownload(
|
chunks = createChunkedDownload(
|
||||||
packageIdentifier: packageIdentifier,
|
packageIdentifier: packageIdentifier,
|
||||||
totalSize: totalSize,
|
totalSize: totalSize,
|
||||||
destinationURL: destinationURL
|
destinationURL: destinationURL,
|
||||||
|
validationInfo: validationInfo
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
chunks = [DownloadChunk(
|
chunks = [DownloadChunk(
|
||||||
@@ -352,11 +518,12 @@ class ChunkedDownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if supportsRange && totalSize > chunkSize {
|
if supportsRange && (validationInfo != nil || totalSize > chunkSize) {
|
||||||
chunks = createChunkedDownload(
|
chunks = createChunkedDownload(
|
||||||
packageIdentifier: packageIdentifier,
|
packageIdentifier: packageIdentifier,
|
||||||
totalSize: totalSize,
|
totalSize: totalSize,
|
||||||
destinationURL: destinationURL
|
destinationURL: destinationURL,
|
||||||
|
validationInfo: validationInfo
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
chunks = [DownloadChunk(
|
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 {
|
if incompleteChunks.isEmpty {
|
||||||
|
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunkConcurrency = min(maxConcurrentChunks, 5)
|
|
||||||
|
|
||||||
actor SpeedTracker {
|
try await downloadChunksSequentially(
|
||||||
private var lastProgressTime = Date()
|
chunks: incompleteChunks,
|
||||||
private var lastDownloadedSize: Int64 = 0
|
url: url,
|
||||||
|
destinationURL: destinationURL,
|
||||||
func updateSpeed(currentDownloaded: Int64) -> Double {
|
headers: headers,
|
||||||
let now = Date()
|
progressHandler: progressHandler,
|
||||||
let timeDiff = now.timeIntervalSince(lastProgressTime)
|
cancellationHandler: cancellationHandler,
|
||||||
|
packageIdentifier: packageIdentifier,
|
||||||
if timeDiff >= 0.5 {
|
totalSize: totalSize,
|
||||||
let sizeDiff = currentDownloaded - lastDownloadedSize
|
validationInfo: validationInfo
|
||||||
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 {
|
if let validationInfo = validationInfo {
|
||||||
private var chunks: [DownloadChunk]
|
try await validateCompleteFile(destinationURL: destinationURL, validationInfo: validationInfo, totalSize: totalSize)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
|
clearChunkedDownloadState(packageIdentifier: packageIdentifier)
|
||||||
@@ -520,4 +594,196 @@ class ChunkedDownloadManager {
|
|||||||
|
|
||||||
try await downloadTask.value
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -843,6 +843,7 @@ class NewDownloadUtils {
|
|||||||
url: url,
|
url: url,
|
||||||
destinationURL: destinationURL,
|
destinationURL: destinationURL,
|
||||||
headers: NetworkConstants.downloadHeaders,
|
headers: NetworkConstants.downloadHeaders,
|
||||||
|
validationURL: package.validationURL,
|
||||||
progressHandler: { progress, downloadedSize, totalSize, speed in
|
progressHandler: { progress, downloadedSize, totalSize, speed in
|
||||||
Task {
|
Task {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@@ -215,14 +215,6 @@ final class GeneralSettingsViewModel: ObservableObject {
|
|||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var chunkSizeMB: Int {
|
|
||||||
get { StorageData.shared.chunkSizeMB }
|
|
||||||
set {
|
|
||||||
StorageData.shared.chunkSizeMB = newValue
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var automaticallyChecksForUpdates: Bool
|
@Published var automaticallyChecksForUpdates: Bool
|
||||||
@Published var automaticallyDownloadsUpdates: Bool
|
@Published var automaticallyDownloadsUpdates: Bool
|
||||||
@@ -487,11 +479,6 @@ struct DownloadSettingsView: View {
|
|||||||
|
|
||||||
ConcurrentDownloadsSettingRow(viewModel: viewModel)
|
ConcurrentDownloadsSettingRow(viewModel: viewModel)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user