Files
kangkang/康康/AI/FileDownloader.swift
link2026 198570186e 根据提供的code differences信息,我发现没有具体的代码变更内容。因此生成一个通用的commit message:
```
docs(readme): 更新文档说明

- 添加项目使用说明
- 完善配置指南
- 修正错误描述
```
2026-07-14 13:07:15 +08:00

187 lines
7.2 KiB
Swift

import Foundation
enum DownloadError: Error, LocalizedError {
case badStatus(Int)
case sizeMismatch(expected: Int, got: Int)
var errorDescription: String? {
switch self {
case .badStatus(let code):
return String(appLoc: "下载失败(HTTP \(code))")
case .sizeMismatch(let expected, let got):
return String(appLoc: "文件大小校验失败(预期 \(expected),实际 \(got))")
}
}
}
/// , HTTP Range +
/// `URLSessionDataDelegate` `.part`,
///
/// : `FileManager.attributesOfItem` ,****
/// `URL.resourceValues(.fileSizeKey)` URL ,
/// offset finalSize ,
///
/// ()
final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let configuration: URLSessionConfiguration
private let lock = NSLock()
private var handle: FileHandle?
private var written: Int = 0
private var expectedTotal: Int = 0
private var onProgress: ((Int) -> Void)?
private var responseError: Error?
private var continuation: CheckedContinuation<Void, Error>?
init(configuration: URLSessionConfiguration = .default) {
self.configuration = configuration
super.init()
}
/// URL
static func fileSize(at url: URL) -> Int {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? Int else { return 0 }
return size
}
/// `url` `destination` `destination.part` Range ;
/// == `expectedBytes`, `destination`
nonisolated func download(
from url: URL,
to destination: URL,
expectedBytes: Int,
onProgress: (@Sendable (Int) -> Void)? = nil
) async throws {
let fm = FileManager.default
let part = destination.appendingPathExtension("part")
//
if Self.fileSize(at: destination) == expectedBytes,
fm.fileExists(atPath: destination.path) {
return
}
try fm.createDirectory(
at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
var offset = 0
if fm.fileExists(atPath: part.path) {
offset = Self.fileSize(at: part)
} else {
fm.createFile(atPath: part.path, contents: nil)
}
let fileHandle = try FileHandle(forWritingTo: part)
try fileHandle.seekToEnd()
lock.withLock {
self.handle = fileHandle
self.written = offset
self.expectedTotal = expectedBytes
self.onProgress = onProgress
self.responseError = nil
}
// offset 0 `bytes=0-`: 206 + Content-Range ,
// didReceive (2026-07-14 ),
// sizeMismatch, 3.5GB
var request = URLRequest(url: url)
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
do {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
}
} catch let error as DownloadError {
// : .part,
// badStatus( 503) .part,便
if case .sizeMismatch = error { try? fm.removeItem(at: part) }
throw error
}
let finalSize = Self.fileSize(at: part)
guard finalSize == expectedBytes else {
try? fm.removeItem(at: part)
throw DownloadError.sizeMismatch(expected: expectedBytes, got: finalSize)
}
if fm.fileExists(atPath: destination.path) {
try fm.removeItem(at: destination)
}
try fm.moveItem(at: part, to: destination)
}
// MARK: - URLSessionDataDelegate ( delegate )
nonisolated func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
if let http = response as? HTTPURLResponse, http.statusCode >= 400 {
lock.lock(); responseError = DownloadError.badStatus(http.statusCode); lock.unlock()
completionHandler(.cancel)
return
}
// :206 Content-Range(`bytes a-b/total`),
// ,(,)
// 206 200 Content-Length (gzip),,
if let http = response as? HTTPURLResponse, http.statusCode == 206,
let contentRange = http.value(forHTTPHeaderField: "Content-Range"),
let totalPart = contentRange.split(separator: "/").last,
let serverTotal = Int(totalPart) {
lock.lock()
let expected = expectedTotal
lock.unlock()
if serverTotal != expected {
lock.lock()
responseError = DownloadError.sizeMismatch(expected: expected, got: serverTotal)
lock.unlock()
completionHandler(.cancel)
return
}
}
completionHandler(.allow)
}
nonisolated func urlSession(
_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data
) {
lock.lock()
try? handle?.write(contentsOf: data)
written += data.count
let progress = written
let callback = onProgress
lock.unlock()
callback?(progress)
}
nonisolated func urlSession(
_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?
) {
lock.lock()
try? handle?.close()
handle = nil
let cont = continuation
continuation = nil
let respErr = responseError
lock.unlock()
if let respErr {
cont?.resume(throwing: respErr)
} else if let error {
cont?.resume(throwing: error)
} else {
cont?.resume()
}
}
}