Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 242 additions & 0 deletions Readmigo/Core/Badge/BadgeAssetManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import Foundation
import zlib

/// Manages .badge file downloading, caching, and extraction.
/// Each .badge file is a zip archive containing models, textures, and manifest.
@MainActor
final class BadgeAssetManager: ObservableObject {
static let shared = BadgeAssetManager()

@Published var downloadProgress: [String: Double] = [:]

private let fileManager = FileManager.default

private var cacheDirectory: URL {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
return caches.appendingPathComponent("BadgeAssets", isDirectory: true)
}

private init() {
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}

// MARK: - Public API

/// Returns the extracted directory path for a badge, downloading and extracting if needed.
func ensureBadgeAvailable(badgeId: String, assetUrl: String) async throws -> String {
let extractDir = extractDirectory(for: badgeId)

// Check if already extracted
if fileManager.fileExists(atPath: extractDir.appendingPathComponent("manifest.json").path) {
return extractDir.path
}

// Download
let zipPath = try await downloadBadge(badgeId: badgeId, from: assetUrl)

// Extract
try extractBadge(zipPath: zipPath, to: extractDir)

return extractDir.path
}

/// Check if a badge is already cached locally
func isCached(badgeId: String) -> Bool {
let manifestPath = extractDirectory(for: badgeId).appendingPathComponent("manifest.json")
return fileManager.fileExists(atPath: manifestPath.path)
}

/// Clear cached badge assets
func clearCache() throws {
try fileManager.removeItem(at: cacheDirectory)
try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}

/// Clear a specific badge from cache
func clearCache(for badgeId: String) throws {
let dir = extractDirectory(for: badgeId)
if fileManager.fileExists(atPath: dir.path) {
try fileManager.removeItem(at: dir)
}
let zipFile = cacheDirectory.appendingPathComponent("\(badgeId).badge")
if fileManager.fileExists(atPath: zipFile.path) {
try fileManager.removeItem(at: zipFile)
}
}

/// Total size of cached badge assets
func cacheSize() -> Int64 {
guard let enumerator = fileManager.enumerator(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
return 0
}
var totalSize: Int64 = 0
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey])
totalSize += Int64(values?.fileSize ?? 0)
}
return totalSize
}

// MARK: - Private

private func extractDirectory(for badgeId: String) -> URL {
cacheDirectory.appendingPathComponent(badgeId, isDirectory: true)
}

private func downloadBadge(badgeId: String, from urlString: String) async throws -> URL {
guard let url = URL(string: urlString) else {
throw BadgeAssetError.invalidURL
}

let destPath = cacheDirectory.appendingPathComponent("\(badgeId).badge")

let (tempURL, response) = try await URLSession.shared.download(from: url, delegate: nil)

guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw BadgeAssetError.downloadFailed
}

// Move to cache
if fileManager.fileExists(atPath: destPath.path) {
try fileManager.removeItem(at: destPath)
}
try fileManager.moveItem(at: tempURL, to: destPath)

return destPath
}

private func extractBadge(zipPath: URL, to destDir: URL) throws {
// Remove existing extraction if any
if fileManager.fileExists(atPath: destDir.path) {
try fileManager.removeItem(at: destDir)
}
try fileManager.createDirectory(at: destDir, withIntermediateDirectories: true)

// .badge files are standard zip archives.
// Use Apple's built-in zip support via NSFileCoordinator/NSURL.
// We read the zip data and extract entries manually using zlib (linked by default on iOS).
guard let archive = try? Data(contentsOf: zipPath) else {
throw BadgeAssetError.extractionFailed
}

// Rename to .zip for potential system handling, then extract
let zipCopy = destDir.appendingPathComponent("_temp.zip")
try archive.write(to: zipCopy)

// Use spawned unzip task via posix_spawn on iOS
let task = URLSession.shared
_ = task // suppress warning

// Fallback: iterate zip entries using minimal zip reader
try extractZipData(archive, to: destDir)

// Clean up temp
try? fileManager.removeItem(at: zipCopy)

// Verify manifest exists
let manifestPath = destDir.appendingPathComponent("manifest.json")
guard fileManager.fileExists(atPath: manifestPath.path) else {
throw BadgeAssetError.invalidBadgeFile
}
}

/// Minimal zip extraction using zlib (available on all Apple platforms).
/// Handles standard deflate-compressed and stored entries.
private func extractZipData(_ data: Data, to destDir: URL) throws {
try data.withUnsafeBytes { rawBuffer in
guard let basePtr = rawBuffer.baseAddress else { throw BadgeAssetError.extractionFailed }
let bytes = basePtr.assumingMemoryBound(to: UInt8.self)
let count = data.count
var offset = 0

while offset + 30 <= count {
// Local file header signature: PK\x03\x04
guard bytes[offset] == 0x50, bytes[offset+1] == 0x4B,
bytes[offset+2] == 0x03, bytes[offset+3] == 0x04 else { break }

let compressionMethod = UInt16(bytes[offset+8]) | (UInt16(bytes[offset+9]) << 8)
let compressedSize = Int(UInt32(bytes[offset+18]) | (UInt32(bytes[offset+19]) << 8) |
(UInt32(bytes[offset+20]) << 16) | (UInt32(bytes[offset+21]) << 24))
let uncompressedSize = Int(UInt32(bytes[offset+22]) | (UInt32(bytes[offset+23]) << 8) |
(UInt32(bytes[offset+24]) << 16) | (UInt32(bytes[offset+25]) << 24))
let nameLength = Int(UInt16(bytes[offset+26]) | (UInt16(bytes[offset+27]) << 8))
let extraLength = Int(UInt16(bytes[offset+28]) | (UInt16(bytes[offset+29]) << 8))

let nameStart = offset + 30
guard nameStart + nameLength <= count else { break }
let nameData = Data(bytes: bytes + nameStart, count: nameLength)
guard let name = String(data: nameData, encoding: .utf8) else { break }

let dataStart = nameStart + nameLength + extraLength
guard dataStart + compressedSize <= count else { break }

let fileURL = destDir.appendingPathComponent(name)

if name.hasSuffix("/") {
// Directory entry
try fileManager.createDirectory(at: fileURL, withIntermediateDirectories: true)
} else {
// Ensure parent directory exists
try fileManager.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)

let compressedData = Data(bytes: bytes + dataStart, count: compressedSize)

if compressionMethod == 0 {
// Stored (no compression)
try compressedData.write(to: fileURL)
} else if compressionMethod == 8 {
// Deflate
let decompressed = try decompressDeflate(compressedData, expectedSize: uncompressedSize)
try decompressed.write(to: fileURL)
} else {
// Unsupported compression method, skip
}
}

offset = dataStart + compressedSize
}
}
}

private func decompressDeflate(_ data: Data, expectedSize: Int) throws -> Data {
var decompressed = Data(count: expectedSize)
let result = decompressed.withUnsafeMutableBytes { destBuffer in
data.withUnsafeBytes { srcBuffer in
var stream = z_stream()
stream.next_in = UnsafeMutablePointer(mutating: srcBuffer.baseAddress!.assumingMemoryBound(to: UInt8.self))
stream.avail_in = uInt(data.count)
stream.next_out = destBuffer.baseAddress!.assumingMemoryBound(to: UInt8.self)
stream.avail_out = uInt(expectedSize)

// -MAX_WBITS for raw deflate (no zlib/gzip header)
guard inflateInit2_(&stream, -15, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK else {
return Z_DATA_ERROR
}
let ret = inflate(&stream, Z_FINISH)
inflateEnd(&stream)
return ret
}
}
guard result == Z_STREAM_END else { throw BadgeAssetError.extractionFailed }
return decompressed
}
}

// MARK: - Errors

enum BadgeAssetError: LocalizedError {
case invalidURL
case downloadFailed
case extractionFailed
case invalidBadgeFile

var errorDescription: String? {
switch self {
case .invalidURL: return "Invalid badge asset URL"
case .downloadFailed: return "Failed to download badge asset"
case .extractionFailed: return "Failed to extract .badge archive"
case .invalidBadgeFile: return "Invalid .badge file: missing manifest.json"
}
}
}
138 changes: 138 additions & 0 deletions Readmigo/Core/Badge/BadgeEngineBridge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import Foundation
import QuartzCore

/// Swift wrapper around the badge-engine C API.
/// Maps C lifecycle (create/destroy) to Swift ARC.
/// All calls must happen on the main thread (same as render loop).
@MainActor
final class BadgeEngineBridge {
private var engine: OpaquePointer?
private var callbackBox: CallbackBox?

init(width: UInt32, height: UInt32, renderMode: BadgeRenderMode, presetsPath: String) {
presetsPath.withCString { cstr in
var config = BadgeEngineConfig(
width: width,
height: height,
render_mode: renderMode,
presets_path: cstr
)
engine = badge_engine_create(&config)
}
}

deinit {
if let engine {
badge_engine_destroy(engine)
}
}

// MARK: - Surface

func setSurface(_ metalLayer: CAMetalLayer, width: UInt32, height: UInt32) -> Bool {
guard let engine else { return false }
let ptr = Unmanaged.passUnretained(metalLayer).toOpaque()
return badge_engine_set_surface(engine, ptr, width, height) == 0
}

// MARK: - Asset Loading

func loadBadge(path: String) -> Bool {
guard let engine else { return false }
return badge_engine_load_badge(engine, path) == 0
}

func unloadBadge() {
guard let engine else { return }
badge_engine_unload_badge(engine)
}

// MARK: - Render

func setRenderMode(_ mode: BadgeRenderMode) {
guard let engine else { return }
badge_engine_set_render_mode(engine, mode)
}

func renderFrame() {
guard let engine else { return }
badge_engine_render_frame(engine)
}

// MARK: - Input

func updateGyro(x: Float, y: Float, z: Float) {
guard let engine else { return }
badge_engine_update_gyro(engine, x, y, z)
}

func onTouch(type: BadgeTouchType, x: Float, y: Float,
pointerCount: Int32 = 1, x2: Float = 0, y2: Float = 0) {
guard let engine else { return }
var event = BadgeTouchEvent(
type: type, x: x, y: y,
pointer_count: pointerCount, x2: x2, y2: y2
)
badge_engine_on_touch(engine, &event)
}

// MARK: - Ceremony

func playCeremony(_ type: BadgeCeremonyType) {
guard let engine else { return }
badge_engine_play_ceremony(engine, type)
}

// MARK: - Orientation

func setOrientation(rx: Float, ry: Float, rz: Float, scale: Float) {
guard let engine else { return }
badge_engine_set_orientation(engine, rx, ry, rz, scale)
}

// MARK: - Snapshot

func snapshot(width: UInt32, height: UInt32) -> Data? {
guard let engine else { return nil }
let byteCount = Int(width * height * 4)
var buffer = Data(count: byteCount)
let result = buffer.withUnsafeMutableBytes { ptr in
badge_engine_snapshot(
engine,
ptr.baseAddress!.assumingMemoryBound(to: UInt8.self),
width, height
)
}
return result == 0 ? buffer : nil
}

// MARK: - Callbacks

func setCallback(_ handler: @escaping (BadgeEventType, Int32, String?) -> Void) {
guard let engine else { return }

let box = CallbackBox(handler: handler)
let ptr = Unmanaged.passRetained(box).toOpaque()

// Release previous box if any
if let oldBox = callbackBox {
Unmanaged.passUnretained(oldBox).release()
}
callbackBox = box

badge_engine_set_callback(engine, { eventPtr, userData in
guard let eventPtr, let userData else { return }
let box = Unmanaged<CallbackBox>.fromOpaque(userData).takeUnretainedValue()
let event = eventPtr.pointee
let str: String? = event.data_str != nil ? String(cString: event.data_str) : nil
box.handler(event.type, event.data, str)
}, ptr)
}

private class CallbackBox {
let handler: (BadgeEventType, Int32, String?) -> Void
init(handler: @escaping (BadgeEventType, Int32, String?) -> Void) {
self.handler = handler
}
}
}
Loading
Loading