426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
793 lines
33 KiB
Swift
793 lines
33 KiB
Swift
import Foundation
|
|
import Darwin
|
|
#if ELIZA_IOS_FULL_BUN_ENGINE
|
|
import ElizaBunEngine
|
|
#endif
|
|
|
|
private let fullBunHostCallCallback: @convention(c) (
|
|
UnsafePointer<CChar>?,
|
|
UnsafePointer<CChar>?,
|
|
Int32
|
|
) -> UnsafeMutablePointer<CChar>? = { 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<CChar>?
|
|
private typealias LastErrorFn = @convention(c) () -> UnsafePointer<CChar>?
|
|
private typealias HostCallbackFn = @convention(c) (
|
|
UnsafePointer<CChar>?,
|
|
UnsafePointer<CChar>?,
|
|
Int32
|
|
) -> UnsafeMutablePointer<CChar>?
|
|
private typealias SetHostCallbackFn = @convention(c) (HostCallbackFn?) -> Int32
|
|
private typealias StartFn = @convention(c) (
|
|
UnsafePointer<CChar>,
|
|
UnsafePointer<CChar>,
|
|
UnsafePointer<CChar>,
|
|
UnsafePointer<CChar>
|
|
) -> Int32
|
|
private typealias StopFn = @convention(c) () -> Int32
|
|
private typealias IsRunningFn = @convention(c) () -> Int32
|
|
private typealias CallFn = @convention(c) (
|
|
UnsafePointer<CChar>,
|
|
UnsafePointer<CChar>
|
|
) -> UnsafeMutablePointer<CChar>?
|
|
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<T>(_ 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
|
|
}
|
|
}
|