import Foundation import Darwin #if ELIZA_IOS_FULL_BUN_ENGINE import ElizaBunEngine #endif private let fullBunHostCallCallback: @convention(c) ( UnsafePointer?, UnsafePointer?, Int32 ) -> UnsafeMutablePointer? = { methodPtr, payloadPtr, timeoutMs in let method = methodPtr.map { String(cString: $0) } ?? "" let payloadJson = payloadPtr.map { String(cString: $0) } ?? "null" let response = FullBunEngineHost.shared.handleHostCall( method: method, payloadJson: payloadJson, timeoutMs: timeoutMs ) return strdup(response) } /// Host for the real Bun iOS engine framework. /// /// Full-engine/App Store builds link `ElizaBunEngine.xcframework` directly so /// the shipped app does not import dynamic loader APIs. Compatibility builds /// keep the optional loader path only in DEBUG development builds without /// embedding the full engine framework. Release builds without the direct-link /// flag fail closed. /// /// IPC security model (full-Bun path): /// - Transport: NDJSON over anonymous stdio pipes. No TCP port is opened by the /// native side. `bun_start(...)` receives the read end of the parent's stdin /// pipe and the write end of its stdout pipe; no other network socket is /// created by the shim. /// - Input validation: the C shim (`eliza_bun_engine_shim.c`) validates all /// NDJSON frames before dispatch: `id` must be numeric, `method` must be a /// JSON string, `payload` is extracted as a bounded value field. The /// `ELIZA_MAX_PROTOCOL_LINE_BYTES` (16 MiB) cap prevents unbounded reads. /// - Host-call allowlist: only `llama_hardware_info`, `llama_load_model`, /// `llama_generate`, `llama_free`, `llama_cancel`, `eliza_tts_synthesize`, /// `eliza_asr_transcribe`, `keep_awake_set`, `stream_emit`, /// `bg_download_start`, `bg_download_status`, and `bg_download_cancel` are /// dispatched by `handleHostCall`. All other method names return /// `{"ok":false,"error":"..."}`. /// - `http_fetch` (JSContext compat path): loopback/local-agent URLs are /// rejected at `HTTPBridge.isLocalLoopback` before a URLRequest is created. /// External fetches go through URLSession with the standard iOS ATS policy. /// - `http_request` IPC (both paths): the path must begin with `/` and must not /// contain `://`. Validated in `MobileAgentBridgePlugin.proxyHttpRequest` and /// enforced by the Bun bridge contract at the agent layer. /// - Filesystem (JSContext compat path): `FSBridge` does not restrict paths /// beyond what the iOS app sandbox enforces. The agent bundle is signed and /// staged inside the app bundle; paths visible to the JSContext are limited to /// the app container by the OS. /// - ABI version check: `load()` verifies the framework reports ABI version "3" /// before accepting any other symbols. A version mismatch is a hard error. final class FullBunEngineHost { static let shared = FullBunEngineHost() private static let expectedAbiVersion = "3" private typealias AbiVersionFn = @convention(c) () -> UnsafePointer? private typealias LastErrorFn = @convention(c) () -> UnsafePointer? private typealias HostCallbackFn = @convention(c) ( UnsafePointer?, UnsafePointer?, Int32 ) -> UnsafeMutablePointer? private typealias SetHostCallbackFn = @convention(c) (HostCallbackFn?) -> Int32 private typealias StartFn = @convention(c) ( UnsafePointer, UnsafePointer, UnsafePointer, UnsafePointer ) -> Int32 private typealias StopFn = @convention(c) () -> Int32 private typealias IsRunningFn = @convention(c) () -> Int32 private typealias CallFn = @convention(c) ( UnsafePointer, UnsafePointer ) -> UnsafeMutablePointer? private typealias FreeFn = @convention(c) (UnsafeMutableRawPointer?) -> Void private var loaded = false #if !ELIZA_IOS_FULL_BUN_ENGINE && DEBUG private var handle: UnsafeMutableRawPointer? #endif private var abiVersionFn: AbiVersionFn? private var lastErrorFn: LastErrorFn? private var setHostCallbackFn: SetHostCallbackFn? private var startFn: StartFn? private var stopFn: StopFn? private var isRunningFn: IsRunningFn? private var callFn: CallFn? private var freeFn: FreeFn? private var running = false /// Delivers one `agentStream*` event to the WebView. Set by the plugin so /// the `stream_emit` host-call (fired per token while `http_request_stream` /// is in flight) reaches `CAPPlugin.notifyListeners` (#12354). Nil until the /// plugin wires it; a stream emit with no sink is a no-op envelope. var streamEventSink: ((_ eventName: String, _ data: [String: Any]) -> Void)? private init() {} var isAvailable: Bool { do { try load() return true } catch { return false } } var abiVersion: String { guard let abi = abiVersionFn?() else { return "unknown" } return String(cString: abi) } var isRunning: Bool { do { try load() let engineRunning = isRunningFn?() == 1 if !engineRunning { running = false } return engineRunning } catch { running = false return false } } func start( bundlePath: String, argv: [String], env: [String: String], appSupportDir: String ) throws { let startedAt = Date() NSLog("[FullBunEngineHost] start requested bundle=\(bundlePath) appSupport=\(appSupportDir) argv=\(argv) envKeys=\(env.keys.sorted())") try load() if running { if isRunningFn?() == 1 { return } running = false } guard let startFn else { throw makeError("ElizaBunEngine missing start symbol") } let argvJson = try encodeJSON(argv) let envJson = try encodeJSON(env) let code = bundlePath.withCString { bundlePtr in argvJson.withCString { argvPtr in envJson.withCString { envPtr in appSupportDir.withCString { supportPtr in startFn(bundlePtr, argvPtr, envPtr, supportPtr) } } } } guard code == 0 else { let detail = lastError() let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) NSLog("[FullBunEngineHost] start failed code=\(code) durationMs=\(durationMs) detail=\(detail)") throw makeError( "ElizaBunEngine start failed with code \(code)" + (detail.isEmpty ? "" : ": \(detail)") ) } running = true let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000) NSLog("[FullBunEngineHost] start succeeded abi=\(abiVersion) durationMs=\(durationMs)") } func stop() { _ = stopFn?() running = false } func call(method: String, payload: Any?) throws -> Any? { try load() guard let callFn else { throw makeError("ElizaBunEngine missing call symbol") } let previousError = lastError() let payloadJson = try encodeJSON(payload ?? NSNull()) let resultPtr = method.withCString { methodPtr in payloadJson.withCString { payloadPtr in callFn(methodPtr, payloadPtr) } } guard let resultPtr else { NSLog("[FullBunEngineHost] call returned null method=\(method) lastError=\(lastError())") throw makeError("ElizaBunEngine call returned null for \(method)") } defer { freeFn?(UnsafeMutableRawPointer(resultPtr)) } let resultJson = String(cString: resultPtr) guard let data = resultJson.data(using: .utf8) else { throw makeError("ElizaBunEngine returned non-UTF8 payload") } let decoded = try JSONSerialization.jsonObject(with: data) if let dict = decoded as? [String: Any], let ok = dict["ok"] as? Bool, ok == false { let message = dict["error"] as? String ?? "unknown full Bun engine error" let currentError = lastError() let diagnostic = !currentError.isEmpty && currentError != message ? currentError : previousError let detail = diagnostic.isEmpty || diagnostic == message ? "" : " (engine error: \(diagnostic))" NSLog("[FullBunEngineHost] call failed method=\(method) error=\(message) diagnostic=\(diagnostic)") throw makeError("\(message)\(detail)") } if let dict = decoded as? [String: Any], let ok = dict["ok"] as? Bool, ok == true { return dict["result"] ?? NSNull() } return decoded } private func load() throws { if loaded { return } NSLog("[FullBunEngineHost] loading ElizaBunEngine") #if ELIZA_IOS_FULL_BUN_ENGINE let loadedAbiVersionFn: AbiVersionFn = { eliza_bun_engine_abi_version() } let loadedLastErrorFn: LastErrorFn = { eliza_bun_engine_last_error() } let loadedSetHostCallbackFn: SetHostCallbackFn = { callback in eliza_bun_engine_set_host_callback(callback) } let loadedStartFn: StartFn = { bundlePath, argvJson, envJson, appSupportDir in eliza_bun_engine_start(bundlePath, argvJson, envJson, appSupportDir) } let loadedStopFn: StopFn = { eliza_bun_engine_stop() } let loadedIsRunningFn: IsRunningFn = { eliza_bun_engine_is_running() } let loadedCallFn: CallFn = { method, payload in eliza_bun_engine_call(method, payload) } let loadedFreeFn: FreeFn = { ptr in eliza_bun_engine_free(ptr) } try installLoadedEngineSymbols( abiVersionFn: loadedAbiVersionFn, lastErrorFn: loadedLastErrorFn, setHostCallbackFn: loadedSetHostCallbackFn, startFn: loadedStartFn, stopFn: loadedStopFn, isRunningFn: loadedIsRunningFn, callFn: loadedCallFn, freeFn: loadedFreeFn ) #elseif DEBUG let binaryPath = try locateFrameworkBinary() guard let openedHandle = dlopen(binaryPath, RTLD_NOW | RTLD_LOCAL) else { throw makeError(String(cString: dlerror())) } do { let loadedAbiVersionFn: AbiVersionFn = try symbol( "eliza_bun_engine_abi_version", in: openedHandle ) let loadedLastErrorFn: LastErrorFn = try symbol( "eliza_bun_engine_last_error", in: openedHandle ) let loadedSetHostCallbackFn: SetHostCallbackFn = try symbol( "eliza_bun_engine_set_host_callback", in: openedHandle ) let loadedStartFn: StartFn = try symbol("eliza_bun_engine_start", in: openedHandle) let loadedStopFn: StopFn = try symbol("eliza_bun_engine_stop", in: openedHandle) let loadedIsRunningFn: IsRunningFn = try symbol( "eliza_bun_engine_is_running", in: openedHandle ) let loadedCallFn: CallFn = try symbol("eliza_bun_engine_call", in: openedHandle) let loadedFreeFn: FreeFn = try symbol("eliza_bun_engine_free", in: openedHandle) try installLoadedEngineSymbols( abiVersionFn: loadedAbiVersionFn, lastErrorFn: loadedLastErrorFn, setHostCallbackFn: loadedSetHostCallbackFn, startFn: loadedStartFn, stopFn: loadedStopFn, isRunningFn: loadedIsRunningFn, callFn: loadedCallFn, freeFn: loadedFreeFn ) self.handle = openedHandle } catch { _ = dlclose(openedHandle) throw error } #else throw makeError( "ElizaBunEngine direct-link symbols are not compiled into this release build" ) #endif } private func installLoadedEngineSymbols( abiVersionFn loadedAbiVersionFn: AbiVersionFn, lastErrorFn loadedLastErrorFn: LastErrorFn, setHostCallbackFn loadedSetHostCallbackFn: SetHostCallbackFn, startFn loadedStartFn: StartFn, stopFn loadedStopFn: StopFn, isRunningFn loadedIsRunningFn: IsRunningFn, callFn loadedCallFn: CallFn, freeFn loadedFreeFn: FreeFn ) throws { guard let abiPointer = loadedAbiVersionFn() else { throw makeError("ElizaBunEngine ABI version returned null") } let loadedAbiVersion = String(cString: abiPointer) guard loadedAbiVersion == Self.expectedAbiVersion else { throw makeError( "ElizaBunEngine ABI mismatch: expected \(Self.expectedAbiVersion), got \(loadedAbiVersion)" ) } let callbackCode = loadedSetHostCallbackFn(fullBunHostCallCallback) guard callbackCode == 0 else { throw makeError("ElizaBunEngine failed to install host callback: \(callbackCode)") } self.abiVersionFn = loadedAbiVersionFn self.lastErrorFn = loadedLastErrorFn self.setHostCallbackFn = loadedSetHostCallbackFn self.startFn = loadedStartFn self.stopFn = loadedStopFn self.isRunningFn = loadedIsRunningFn self.callFn = loadedCallFn self.freeFn = loadedFreeFn self.loaded = true NSLog("[FullBunEngineHost] loaded ElizaBunEngine abi=\(loadedAbiVersion)") } #if !ELIZA_IOS_FULL_BUN_ENGINE && DEBUG private func locateFrameworkBinary() throws -> String { let relative = "ElizaBunEngine.framework/ElizaBunEngine" let candidates = [ Bundle.main.privateFrameworksURL?.appendingPathComponent(relative).path, Bundle.main.bundleURL.appendingPathComponent("Frameworks").appendingPathComponent(relative).path, Bundle.main.url( forResource: "ElizaBunEngine", withExtension: nil, subdirectory: "Frameworks/ElizaBunEngine.framework" )?.path, ].compactMap { $0 } for candidate in candidates where FileManager.default.fileExists(atPath: candidate) { return candidate } throw makeError("ElizaBunEngine.framework is not embedded in the app bundle") } private func symbol(_ name: String, in handle: UnsafeMutableRawPointer) throws -> T { guard let pointer = dlsym(handle, name) else { throw makeError("ElizaBunEngine missing symbol \(name)") } return unsafeBitCast(pointer, to: T.self) } #endif private func encodeJSON(_ value: Any) throws -> String { let data = try JSONSerialization.data(withJSONObject: value) return String(data: data, encoding: .utf8) ?? "{}" } private func lastError() -> String { guard let pointer = lastErrorFn?() else { return "" } return String(cString: pointer) } private func makeError(_ message: String) -> NSError { NSError( domain: "ElizaBunEngine", code: -1, userInfo: [NSLocalizedDescriptionKey: message] ) } fileprivate func handleHostCall( method: String, payloadJson: String, timeoutMs: Int32 ) -> String { _ = timeoutMs NSLog("[FullBunEngineHost] host call method=\(method) payloadBytes=\(payloadJson.lengthOfBytes(using: .utf8)) timeoutMs=\(timeoutMs)") do { let payload = try decodeHostPayload(payloadJson) switch method { case "llama_hardware_info": return encodeHostEnvelope(ok: true, result: LlamaBridgeImpl.shared.hardwareInfo().asDict()) case "llama_load_model": return try handleLoadModel(payload) case "llama_generate": return try handleGenerate(payload) case "llama_free": return handleFree(payload) case "llama_cancel": return handleCancel(payload) case "eliza_tts_synthesize": return handleTtsSynthesize(payload) case "eliza_asr_transcribe": return handleAsrTranscribe(payload) case "keep_awake_set": // Hold the iOS idle timer open while an in-process model // download is active so auto-lock cannot suspend the runtime // mid-transfer (#11841). Reference-counted natively. let enabled = boolValue(payload, "enabled") ?? false KeepAwakeBridge.shared.setEnabled(enabled) return encodeHostEnvelope(ok: true, result: ["enabled": NSNumber(value: enabled)]) case "stream_emit": // One chat-stream event (response head / token chunk / complete) // fired by the bridge's `http_request_stream` handler while it // runs. Forward it to the WebView as the matching `agentStream*` // Capacitor event so the TS adapter reconstructs a live // ReadableStream (#12354). return handleStreamEmit(payload) case "bg_download_start": // Route the large on-device model file through a native // background URLSession so the ~5 GB transfer survives the app // backgrounding / the device locking (#11841). The downloader // polls `bg_download_status` for progress + completion. guard let id = stringValue(payload, "id"), !id.isEmpty, let url = stringValue(payload, "url"), !url.isEmpty, let destPath = stringValue(payload, "destPath"), !destPath.isEmpty else { return encodeHostEnvelope( ok: false, error: "bg_download_start requires id, url, and destPath" ) } let headers = stringMapValue(payload, "headers") ?? [:] let total = int64Value(payload, "expectedTotalBytes") ?? 0 return encodeHostEnvelope(ok: true, result: BackgroundDownloadBridge.shared.start( id: id, urlString: url, headers: headers, destPath: destPath, expectedTotalBytes: total )) case "bg_download_status": guard let id = stringValue(payload, "id"), !id.isEmpty else { return encodeHostEnvelope(ok: false, error: "bg_download_status requires id") } return encodeHostEnvelope(ok: true, result: BackgroundDownloadBridge.shared.status(id: id)) case "bg_download_cancel": guard let id = stringValue(payload, "id"), !id.isEmpty else { return encodeHostEnvelope(ok: false, error: "bg_download_cancel requires id") } return encodeHostEnvelope(ok: true, result: BackgroundDownloadBridge.shared.cancel(id: id)) default: return encodeHostEnvelope( ok: false, error: "Unknown native host call method: \(method)" ) } } catch { return encodeHostEnvelope(ok: false, error: error.localizedDescription) } } private func handleLoadModel(_ payload: [String: Any]) throws -> String { guard let path = stringValue(payload, "path") ?? stringValue(payload, "modelPath"), !path.isEmpty else { return encodeHostEnvelope(ok: false, error: "llama_load_model requires path") } let contextSize = uint32Value(payload, "context_size") ?? uint32Value(payload, "contextSize") ?? 4096 let useGPU = boolValue(payload, "use_gpu") ?? boolValue(payload, "useGpu") ?? true let threads = int32Value(payload, "threads") ?? int32Value(payload, "maxThreads") let result = LlamaBridgeImpl.shared.loadModel( path: path, contextSize: contextSize, useGPU: useGPU, threads: threads ) if let error = result.error { return encodeHostEnvelope(ok: false, error: error) } guard let contextId = result.contextId else { return encodeHostEnvelope(ok: false, error: "llama_load_model returned no context_id") } return encodeHostEnvelope(ok: true, result: [ "context_id": NSNumber(value: contextId), "contextId": NSNumber(value: contextId), "modelPath": path, "contextSize": NSNumber(value: contextSize), "useGpu": NSNumber(value: useGPU), ]) } private func handleGenerate(_ payload: [String: Any]) throws -> String { guard let contextId = int64Value(payload, "context_id") ?? int64Value(payload, "contextId") else { return encodeHostEnvelope(ok: false, error: "llama_generate requires context_id") } guard let prompt = stringValue(payload, "prompt"), !prompt.isEmpty else { return encodeHostEnvelope(ok: false, error: "llama_generate requires prompt") } let maxTokens = int32Value(payload, "max_tokens") ?? int32Value(payload, "maxTokens") ?? 256 let temperature = floatValue(payload, "temperature") ?? 0.7 let topP = floatValue(payload, "top_p") ?? floatValue(payload, "topP") ?? 0.95 let topK = int32Value(payload, "top_k") ?? int32Value(payload, "topK") ?? 40 let stopSequences = stringArrayValue(payload, "stop") ?? stringArrayValue(payload, "stopSequences") ?? [] let generate = { LlamaBridgeImpl.shared.generate( contextId: contextId, prompt: prompt, maxTokens: maxTokens, temperature: temperature, topP: topP, topK: topK, stopSequences: stopSequences ) } let result: LlamaGenerateResult if let queue = LlamaBridgeImpl.shared.workQueue(for: contextId) { result = queue.sync(execute: generate) } else { result = generate() } if let error = result.error { return encodeHostEnvelope(ok: false, error: error) } return encodeHostEnvelope(ok: true, result: [ "text": result.text, "promptTokens": NSNumber(value: result.promptTokens), "outputTokens": NSNumber(value: result.outputTokens), "durationMs": NSNumber(value: result.durationMs), ]) } private func handleFree(_ payload: [String: Any]) -> String { guard let contextId = int64Value(payload, "context_id") ?? int64Value(payload, "contextId") else { return encodeHostEnvelope(ok: true, result: ["freed": false]) } LlamaBridgeImpl.shared.free(contextId: contextId) return encodeHostEnvelope(ok: true, result: [ "freed": true, "context_id": NSNumber(value: contextId), ]) } private func handleCancel(_ payload: [String: Any]) -> String { guard let contextId = int64Value(payload, "context_id") ?? int64Value(payload, "contextId") else { return encodeHostEnvelope(ok: true, result: ["cancelled": false]) } LlamaBridgeImpl.shared.cancel(contextId: contextId) return encodeHostEnvelope(ok: true, result: [ "cancelled": true, "context_id": NSNumber(value: contextId), ]) } /// Translate one `stream_emit` frame into the matching `agentStream*` /// Capacitor event and hand it to the WebView. The frame `kind` mirrors the /// Android streaming contract so `createNativeStreamingResponse` consumes it /// unmodified: `response` → `agentStreamResponse`, `chunk` → /// `agentStreamChunk`, `complete` → `agentStreamComplete` (#12354). private func handleStreamEmit(_ payload: [String: Any]) -> String { guard let streamId = stringValue(payload, "streamId"), !streamId.isEmpty else { return encodeHostEnvelope(ok: false, error: "stream_emit requires streamId") } guard let kind = stringValue(payload, "kind") else { return encodeHostEnvelope(ok: false, error: "stream_emit requires kind") } guard let sink = streamEventSink else { // No WebView listener is wired (e.g. a smoke harness). Acknowledge so // the bridge keeps streaming rather than tearing the turn down. return encodeHostEnvelope(ok: true, result: ["delivered": false]) } switch kind { case "response": var data: [String: Any] = [ "streamId": streamId, "status": NSNumber(value: intValue(payload, "status") ?? 200), ] if let statusText = stringValue(payload, "statusText") { data["statusText"] = statusText } if let headers = stringMapValue(payload, "headers") { data["headers"] = headers } sink("agentStreamResponse", data) case "chunk": guard let dataBase64 = stringValue(payload, "dataBase64") else { return encodeHostEnvelope(ok: false, error: "stream_emit chunk requires dataBase64") } sink("agentStreamChunk", ["streamId": streamId, "dataBase64": dataBase64]) case "complete": var data: [String: Any] = ["streamId": streamId] if let error = stringValue(payload, "error") { data["error"] = error } sink("agentStreamComplete", data) default: return encodeHostEnvelope(ok: false, error: "stream_emit unknown kind: \(kind)") } return encodeHostEnvelope(ok: true, result: ["delivered": true]) } private func handleTtsSynthesize(_ payload: [String: Any]) -> String { guard let bundleDir = stringValue(payload, "bundleDir") ?? stringValue(payload, "bundle_dir"), !bundleDir.isEmpty else { return encodeHostEnvelope(ok: false, error: "eliza_tts_synthesize requires bundleDir") } guard let text = stringValue(payload, "text"), !text.isEmpty else { return encodeHostEnvelope(ok: false, error: "eliza_tts_synthesize requires text") } let speakerPresetId = stringValue(payload, "speakerPresetId") ?? stringValue(payload, "speaker_preset_id") ?? stringValue(payload, "voice") ?? stringValue(payload, "voiceId") let maxSamples = intValue(payload, "maxSamples") ?? intValue(payload, "max_samples") ?? 24_000 * 60 let result = LlamaBridgeImpl.shared.synthesizeSpeech( bundleDir: bundleDir, text: text, speakerPresetId: speakerPresetId, maxSamples: maxSamples ) if let error = result.error { return encodeHostEnvelope(ok: false, error: error) } var payload: [String: Any] = [ "contentType": result.contentType, "sampleRate": NSNumber(value: result.sampleRate), "samples": NSNumber(value: result.samples), "durationMs": NSNumber(value: result.durationMs), ] if let audioFilePath = result.audioFilePath, !audioFilePath.isEmpty { payload["audioFilePath"] = audioFilePath } else { payload["audioBase64"] = result.audioBase64 } return encodeHostEnvelope(ok: true, result: payload) } /// Wire format: `pcm` is mono fp32 audio in [-1, 1] carried as a JSON number /// array (no base64). `bridge.ts` encodes the same way. `sampleRate` is the /// source rate in Hz; the inference slice resamples internally as needed. private func handleAsrTranscribe(_ payload: [String: Any]) -> String { guard let bundleDir = stringValue(payload, "bundleDir") ?? stringValue(payload, "bundle_dir"), !bundleDir.isEmpty else { return encodeHostEnvelope(ok: false, error: "eliza_asr_transcribe requires bundleDir") } guard let pcm = floatArrayValue(payload, "pcm"), !pcm.isEmpty else { return encodeHostEnvelope(ok: false, error: "eliza_asr_transcribe requires pcm") } let sampleRate = intValue(payload, "sampleRate") ?? intValue(payload, "sample_rate") ?? 16_000 let result = LlamaBridgeImpl.shared.transcribeSpeech( bundleDir: bundleDir, pcm: pcm, sampleRate: sampleRate ) if let error = result.error { return encodeHostEnvelope(ok: false, error: error) } return encodeHostEnvelope(ok: true, result: [ "text": result.text, "durationMs": NSNumber(value: result.durationMs), ]) } private func decodeHostPayload(_ json: String) throws -> [String: Any] { guard let data = json.data(using: .utf8) else { return [:] } let value = try JSONSerialization.jsonObject(with: data) return value as? [String: Any] ?? [:] } private func encodeHostEnvelope( ok: Bool, result: Any? = nil, error: String? = nil ) -> String { var object: [String: Any] = ["ok": ok] if let result { object["result"] = result } else if ok { object["result"] = NSNull() } if let error { object["error"] = error } guard JSONSerialization.isValidJSONObject(object), let data = try? JSONSerialization.data(withJSONObject: object), let json = String(data: data, encoding: .utf8) else { let fallback = (error ?? "Failed to encode native host response") .replacingOccurrences(of: "\"", with: "\\\"") return "{\"ok\":false,\"error\":\"\(fallback)\"}" } return json } private func stringValue(_ payload: [String: Any], _ key: String) -> String? { payload[key] as? String } private func boolValue(_ payload: [String: Any], _ key: String) -> Bool? { if let value = payload[key] as? Bool { return value } if let value = payload[key] as? NSNumber { return value.boolValue } if let value = payload[key] as? String { if value == "true" || value == "1" { return true } if value == "false" || value == "0" { return false } } return nil } private func int32Value(_ payload: [String: Any], _ key: String) -> Int32? { if let value = payload[key] as? NSNumber { return value.int32Value } if let value = payload[key] as? String, let parsed = Int32(value) { return parsed } return nil } private func intValue(_ payload: [String: Any], _ key: String) -> Int? { if let value = payload[key] as? NSNumber { return value.intValue } if let value = payload[key] as? String, let parsed = Int(value) { return parsed } return nil } private func int64Value(_ payload: [String: Any], _ key: String) -> Int64? { if let value = payload[key] as? NSNumber { return value.int64Value } if let value = payload[key] as? String, let parsed = Int64(value) { return parsed } return nil } private func uint32Value(_ payload: [String: Any], _ key: String) -> UInt32? { if let value = payload[key] as? NSNumber { return value.uint32Value } if let value = payload[key] as? String, let parsed = UInt32(value) { return parsed } return nil } private func floatValue(_ payload: [String: Any], _ key: String) -> Float? { if let value = payload[key] as? NSNumber { return value.floatValue } if let value = payload[key] as? String, let parsed = Float(value) { return parsed } return nil } private func stringMapValue(_ payload: [String: Any], _ key: String) -> [String: String]? { guard let raw = payload[key] as? [String: Any] else { return nil } var out: [String: String] = [:] for (mapKey, value) in raw { if let string = value as? String { out[mapKey] = string } else if let number = value as? NSNumber { out[mapKey] = number.stringValue } } return out } private func stringArrayValue(_ payload: [String: Any], _ key: String) -> [String]? { if let values = payload[key] as? [String] { return values } if let values = payload[key] as? [Any] { return values.compactMap { $0 as? String } } return nil } private func floatArrayValue(_ payload: [String: Any], _ key: String) -> [Float]? { if let values = payload[key] as? [NSNumber] { return values.map { $0.floatValue } } if let values = payload[key] as? [Any] { return values.compactMap { ($0 as? NSNumber)?.floatValue } } return nil } }