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? 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) 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() } } }