- AIRuntime 加 actor 内串行推理闸门,封死 LLM/VL in-flight 并发解码窄口(jetsam OOM 根因) - prepare 的 .loading 改轮询等待消除假就绪竞态;就绪判据 isReady→isComplete 防半下载崩溃 - applyReanalyzed 重新解读时 unlink 旧 Asset,消除 Vault 孤儿图片(§6 隐私承诺) - parseReportJSON 改 extractBalancedJSON + 裸数组兜底,防 VL 多项输出被静默截断丢指标 - 临时文件改 completeUnlessOpen 修锁屏写失败;parseDate 支持多格式防归档年份错位 - TimelineEntry/DayDetailSheet 修「偏高」文案与血压箭头方向(偏低指标不再显示相反结论) - FileVault.wipe 容错;HealthExportSheet 异常关键词排除否定句;modelTag 取实际枚举值 - 删除 B1-B5 + ArchiveFlow 死代码(含违反 §6 的 AES 加密文案) - 补 3 个回归测试,编译 + 测试全部通过
107 lines
3.6 KiB
Swift
107 lines
3.6 KiB
Swift
import Foundation
|
|
import UIKit
|
|
|
|
enum FileVaultError: Error {
|
|
case readFailed
|
|
case writeFailed
|
|
case removeFailed
|
|
case decodeFailed
|
|
}
|
|
|
|
/// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全),
|
|
/// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。
|
|
final class FileVault: @unchecked Sendable {
|
|
nonisolated static let shared: FileVault = {
|
|
do {
|
|
let appSupport = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let vaultURL = appSupport.appendingPathComponent("Vault", isDirectory: true)
|
|
return try FileVault(rootURL: vaultURL)
|
|
} catch {
|
|
fatalError("FileVault.shared init failed: \(error)")
|
|
}
|
|
}()
|
|
|
|
let rootURL: URL
|
|
|
|
init(rootURL: URL) throws {
|
|
self.rootURL = rootURL
|
|
try FileManager.default.createDirectory(
|
|
at: rootURL,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.protectionKey: FileProtectionType.complete]
|
|
)
|
|
}
|
|
|
|
struct SavedAsset {
|
|
let relativePath: String
|
|
let bytes: Int
|
|
}
|
|
|
|
// MARK: - Path Safety
|
|
|
|
nonisolated private func resolveSafePath(_ relativePath: String) throws -> URL {
|
|
guard !relativePath.contains("/"),
|
|
!relativePath.contains(".."),
|
|
!relativePath.isEmpty else {
|
|
throw FileVaultError.readFailed
|
|
}
|
|
let url = rootURL.appendingPathComponent(relativePath)
|
|
guard url.path.hasPrefix(rootURL.path) else {
|
|
throw FileVaultError.readFailed
|
|
}
|
|
return url
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
nonisolated func writeJPEG(_ image: UIImage, quality: CGFloat = 0.85) throws -> SavedAsset {
|
|
guard let data = image.jpegData(compressionQuality: quality) else {
|
|
throw FileVaultError.writeFailed
|
|
}
|
|
let filename = "\(UUID().uuidString).jpg"
|
|
let url = rootURL.appendingPathComponent(filename)
|
|
try data.write(to: url, options: [.atomic, .completeFileProtection])
|
|
return SavedAsset(relativePath: filename, bytes: data.count)
|
|
}
|
|
|
|
nonisolated func loadImage(relativePath: String) throws -> UIImage {
|
|
let url = try resolveSafePath(relativePath)
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: url)
|
|
} catch {
|
|
throw FileVaultError.readFailed
|
|
}
|
|
guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed }
|
|
return image
|
|
}
|
|
|
|
nonisolated func remove(relativePath: String) throws {
|
|
let url = try resolveSafePath(relativePath)
|
|
do {
|
|
try FileManager.default.removeItem(at: url)
|
|
} catch {
|
|
throw FileVaultError.removeFailed
|
|
}
|
|
}
|
|
|
|
/// 清空 Vault 全部文件。单个文件删除失败(被占用/权限)不中断,继续删其余;
|
|
/// 最后复查仍有残留才抛错 ——「永久删除 / 全清」语义下不能因一个文件卡住而留下隐私残留。
|
|
nonisolated func wipe() throws {
|
|
let fm = FileManager.default
|
|
let contents = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
|
|
for url in contents {
|
|
try? fm.removeItem(at: url)
|
|
}
|
|
let remaining = (try? fm.contentsOfDirectory(at: rootURL, includingPropertiesForKeys: nil)) ?? []
|
|
if !remaining.isEmpty {
|
|
throw FileVaultError.removeFailed
|
|
}
|
|
}
|
|
}
|