feat(models): 模型自动下载(我的·模型管理) + 断点续传 + 旁路导入

实现 spec(2026-05-29-model-download-design)的模型分发功能:
- ModelManifest: 硬编码功能文件清单 + base URL https://file.myv0.com/
- FileDownloader: URLSessionDataDelegate 分块写盘,HTTP Range 断点续传 + 大小校验
  (根因修复:URL.resourceValues 会缓存文件大小,续传时先读 offset 再读 finalSize
   会拿到下载前的陈旧值导致校验误判;改用 FileManager.attributesOfItem)
- ModelDownloadService: @MainActor @Observable 编排逐文件下载,聚合进度/速度,
  支持下载全部/暂停/重试,以及旁路文件导入
- ModelStore: 新增 fileURL/localBytes/isComplete(可注入清单)/importModel(补 VL)
- ModelManagementView: 分模型卡片(状态/进度/速度) + 下载全部/暂停
  + NWPathMonitor 蜂窝提示 + 从文件导入(离线兜底)
- MeView: 模型管理卡改 NavigationLink + 动态状态(已就绪/下载中/N就绪)

测试(Swift Testing): Manifest 清单/字节数、Store 路径/校验/导入、
DownloadState、FileDownloader(URLProtocol mock:下载/Range续传/大小校验)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
link2026
2026-05-29 23:19:51 +08:00
parent 6ccbe4ac55
commit 062c027c77
10 changed files with 959 additions and 3 deletions

View File

@@ -0,0 +1,158 @@
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 "下载失败(HTTP \(code))"
case .sizeMismatch(let expected, let got):
return "文件大小校验失败(预期 \(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 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.lock()
self.handle = fileHandle
self.written = offset
self.onProgress = onProgress
self.responseError = nil
lock.unlock()
var request = URLRequest(url: url)
if offset > 0 {
request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range")
}
let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
defer { session.finishTasksAndInvalidate() }
// didCompleteWithError ( delegate , didReceive)
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
lock.lock()
self.continuation = cont
lock.unlock()
session.dataTask(with: request).resume()
}
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)
} else {
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()
}
}
}