主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
+ pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存
注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.5 KiB
Swift
159 lines
5.5 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 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()
|
|
}
|
|
}
|
|
}
|