diff --git a/体己/Persistence/FileVault.swift b/体己/Persistence/FileVault.swift new file mode 100644 index 0000000..46ae4fd --- /dev/null +++ b/体己/Persistence/FileVault.swift @@ -0,0 +1,72 @@ +import Foundation +import UIKit + +enum FileVaultError: Error { + case readFailed + case writeFailed + case removeFailed + case decodeFailed +} + +final class FileVault { + 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 + } + + 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) + } + + func loadImage(relativePath: String) throws -> UIImage { + let url = rootURL.appendingPathComponent(relativePath) + let data = try Data(contentsOf: url) + guard let image = UIImage(data: data) else { throw FileVaultError.decodeFailed } + return image + } + + func remove(relativePath: String) throws { + let url = rootURL.appendingPathComponent(relativePath) + try FileManager.default.removeItem(at: url) + } + + 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) + } + } +} diff --git a/体己Tests/FileVaultTests.swift b/体己Tests/FileVaultTests.swift new file mode 100644 index 0000000..44d5ccd --- /dev/null +++ b/体己Tests/FileVaultTests.swift @@ -0,0 +1,48 @@ +import Testing +import UIKit +@testable import 体己 + +@MainActor +struct FileVaultTests { + + private func makeIsolatedVault() throws -> FileVault { + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + return try FileVault(rootURL: temp) + } + + @Test func writeAndReadJPEGRoundtrip() throws { + let vault = try makeIsolatedVault() + let image = UIImage(systemName: "doc")! + + let saved = try vault.writeJPEG(image, quality: 0.8) + + #expect(saved.bytes > 0) + #expect(saved.relativePath.hasSuffix(".jpg")) + + let loaded = try vault.loadImage(relativePath: saved.relativePath) + #expect(loaded.size != .zero) + } + + @Test func removeMakesFileGone() throws { + let vault = try makeIsolatedVault() + let saved = try vault.writeJPEG(UIImage(systemName: "doc")!) + + try vault.remove(relativePath: saved.relativePath) + + #expect(throws: (any Error).self) { + _ = try vault.loadImage(relativePath: saved.relativePath) + } + } + + @Test func wipeRemovesAllFiles() throws { + let vault = try makeIsolatedVault() + let a = try vault.writeJPEG(UIImage(systemName: "doc")!) + let b = try vault.writeJPEG(UIImage(systemName: "folder")!) + + try vault.wipe() + + #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: a.relativePath) } + #expect(throws: (any Error).self) { _ = try vault.loadImage(relativePath: b.relativePath) } + } +}