import Foundation import UIKit import ImageIO enum FileVaultError: Error { case readFailed case writeFailed case removeFailed case decodeFailed } /// `@unchecked Sendable`:rootURL 是 let,方法只 I/O 到沙盒目录(线程安全), /// 可被任意 actor / Task 跨边界访问。实例方法显式 `nonisolated`,见 ModelStore 同款注释。 /// 类级 `nonisolated`:工程开了 `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`,默认会把本类 /// 连同 `thumbnailCache`(非 Sendable 的 NSCache)推成 MainActor,令 nonisolated I/O 方法 / 单例 /// 初始化访问不了;本类是纯文件 I/O + 缓存工具,必须脱离 MainActor 供任意 actor 调用,故整类标 nonisolated。 nonisolated 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 /// 已降采样图片的内存缓存。NSCache 线程安全、内存吃紧时系统自动回收; /// key = "相对路径@目标像素",避免 TabView 翻页 / 列表滚动反复读盘解码同一张图。 /// 只缓存降采样后的小图(几百 KB),不缓存全分辨率原图。 /// `nonisolated(unsafe)`:本工程默认 MainActor 隔离,非 Sendable 的 NSCache 存储属性即便整类标 /// nonisolated 仍被推成 MainActor,令各 nonisolated I/O 方法访问不到;NSCache 本身线程安全,故 unsafe 豁免。 private nonisolated(unsafe) let thumbnailCache: NSCache = { let cache = NSCache() cache.countLimit = 40 // 关键:按「解码后真实字节数」限制总量,而非只数张数。否则 40 张全屏 2000px 大图 // (各 ~12-16MB)就能占数百 MB 把 App 推过内存上限;混在同一缓存里的小缩略图也会被错误计权。 cache.totalCostLimit = 96 * 1024 * 1024 // 96MB return cache }() 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) } /// 把已录制好的临时文件(如问诊录音 m4a)搬进 Vault,并打上硬件级文件保护。 /// 录音过程写在 `tmp`(不加密、可边录边写),录完调用本方法落库——与 writeJPEG 一样以 /// `.complete` 保护落盘(§6 隐私:Vault 全目录硬件加密)。move 失败(源不存在/占用)抛 writeFailed。 /// 返回相对路径 + 字节数,供调用方建 `Asset`。源临时文件搬走后即被移除。 nonisolated func importFile(at sourceURL: URL, preferredExtension ext: String) throws -> SavedAsset { let fm = FileManager.default let filename = "\(UUID().uuidString).\(ext)" let dest = rootURL.appendingPathComponent(filename) do { if fm.fileExists(atPath: dest.path) { try fm.removeItem(at: dest) } try fm.moveItem(at: sourceURL, to: dest) try fm.setAttributes([.protectionKey: FileProtectionType.complete], ofItemAtPath: dest.path) } catch { throw FileVaultError.writeFailed } let bytes = (try? fm.attributesOfItem(atPath: dest.path))?[.size] as? Int ?? 0 return SavedAsset(relativePath: filename, bytes: bytes) } /// 取 Vault 内文件的绝对 URL(只读用,如 AVAudioPlayer 播放录音)。 /// 走与读写一致的路径安全校验(禁子目录 / `..` 越界)。App 在前台即解锁,可正常读取受保护文件。 nonisolated func absoluteURL(forReading relativePath: String) throws -> URL { try resolveSafePath(relativePath) } 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 } /// 按目标最大边降采样加载。用 ImageIO 直接解出缩略图,**绝不**把全分辨率位图载进内存: /// 一张 4000×3000 体检照全量解码是 ~48MB RGBA,翻几页就 jetsam;降到 ≤2000px 后仅几 MB。 /// 自动尊重 EXIF 方向。结果按尺寸缓存,翻页/滚动回看命中缓存不再读盘。 /// 失败(锁屏读不到 / 损坏)抛 readFailed,与 loadImage 一致,UI 显示占位即可。 nonisolated func loadDownsampledImage(relativePath: String, maxPixelSize: CGFloat) throws -> UIImage { let cacheKey = "\(relativePath)@\(Int(maxPixelSize))" as NSString if let cached = thumbnailCache.object(forKey: cacheKey) { return cached } let url = try resolveSafePath(relativePath) let srcOptions: [CFString: Any] = [kCGImageSourceShouldCache: false] guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOptions as CFDictionary) else { throw FileVaultError.readFailed } let thumbOptions: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, // 应用 EXIF 旋转,免得横竖颠倒 kCGImageSourceShouldCacheImmediately: true, // 在后台线程就解码,不拖到主线程绘制时 kCGImageSourceThumbnailMaxPixelSize: maxPixelSize ] guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOptions as CFDictionary) else { throw FileVaultError.decodeFailed } let image = UIImage(cgImage: cg) // cost = 解码后位图字节数,让 NSCache 按真实内存占用淘汰(大图少留、小图多留)。 let cost = cg.bytesPerRow * cg.height thumbnailCache.setObject(image, forKey: cacheKey, cost: cost) return image } nonisolated func remove(relativePath: String) throws { let url = try resolveSafePath(relativePath) do { try FileManager.default.removeItem(at: url) } catch { throw FileVaultError.removeFailed } // 删文件后清掉降采样缓存,避免详情页仍显示已删原图(删除次数极少,整清无虞)。 thumbnailCache.removeAllObjects() } /// 清空 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)) ?? [] thumbnailCache.removeAllObjects() if !remaining.isEmpty { throw FileVaultError.removeFailed } } }