| import Foundation |
| import CryptoKit |
|
|
| |
| |
| |
| |
| |
| |
| public struct AssetBundle: Sendable { |
| |
| public static let supportedSchemas = 2...2 |
|
|
| public struct AssetEntry: Codable, Sendable { |
| public let file: String |
| public let sha256: String |
| public let sizeBytes: Int64 |
| } |
|
|
| public struct BundleManifest: Codable, Sendable { |
| public let schemaVersion: Int |
| public let bundleVersion: String |
| public let assets: [String: AssetEntry] |
| public let extra: [String: String]? |
| } |
|
|
| public let root: URL |
| public let manifest: BundleManifest |
|
|
| |
| |
| |
| |
| |
| |
| public init(at url: URL, verifyHashes: Bool = false) throws { |
| root = url |
| let manifestURL = url.appendingPathComponent("manifest.json") |
| guard FileManager.default.fileExists(atPath: manifestURL.path) else { |
| throw SpeechError.assetBundleNotFound(url.path) |
| } |
| do { |
| manifest = try JSONDecoder().decode( |
| BundleManifest.self, from: Data(contentsOf: manifestURL)) |
| } catch { |
| throw SpeechError.assetMissing("manifest.json (unreadable: \(error.localizedDescription))") |
| } |
| guard Self.supportedSchemas.contains(manifest.schemaVersion) else { |
| throw SpeechError.assetSchemaUnsupported( |
| found: manifest.schemaVersion, supported: Self.supportedSchemas) |
| } |
| for (name, entry) in manifest.assets { |
| let assetURL = root.appendingPathComponent(entry.file) |
| guard FileManager.default.fileExists(atPath: assetURL.path) else { |
| throw SpeechError.assetMissing(name) |
| } |
| if verifyHashes { |
| let actual = try Self.sha256(of: assetURL) |
| guard actual == entry.sha256 else { |
| throw SpeechError.assetCorrupted( |
| name: name, expectedSHA256: entry.sha256, actualSHA256: actual) |
| } |
| } |
| } |
| } |
|
|
| |
| public func url(_ logicalName: String) throws -> URL { |
| guard let entry = manifest.assets[logicalName] else { |
| throw SpeechError.assetMissing(logicalName) |
| } |
| return root.appendingPathComponent(entry.file) |
| } |
|
|
| |
| public static func named(_ name: String, in hostBundle: Bundle = .main, |
| verifyHashes: Bool = false) throws -> AssetBundle { |
| guard let url = hostBundle.url(forResource: name, withExtension: "bundle") else { |
| throw SpeechError.assetBundleNotFound("\(name).bundle in \(hostBundle.bundlePath)") |
| } |
| return try AssetBundle(at: url, verifyHashes: verifyHashes) |
| } |
|
|
| |
| |
| static func sha256(of url: URL) throws -> String { |
| var isDir: ObjCBool = false |
| FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) |
| var hasher = SHA256() |
| if isDir.boolValue { |
| let files = (FileManager.default.subpaths(atPath: url.path) ?? []) |
| .sorted() |
| .map { url.appendingPathComponent($0) } |
| .filter { (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true } |
| for f in files { |
| let rel = f.path.replacingOccurrences(of: url.path + "/", with: "") |
| hasher.update(data: Data(rel.utf8)) |
| try hashFileContents(f, into: &hasher) |
| } |
| } else { |
| try hashFileContents(url, into: &hasher) |
| } |
| return hasher.finalize().map { String(format: "%02x", $0) }.joined() |
| } |
|
|
| private static func hashFileContents(_ url: URL, into hasher: inout SHA256) throws { |
| let handle = try FileHandle(forReadingFrom: url) |
| defer { try? handle.close() } |
| while autoreleasepool(invoking: { |
| let chunk = handle.readData(ofLength: 8 << 20) |
| if chunk.isEmpty { return false } |
| hasher.update(data: chunk) |
| return true |
| }) {} |
| } |
| } |
|
|