@elizaos/capacitor-bun-runtime 2.0.11-beta.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ElizaosCapacitorBunRuntime.podspec +54 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/esm/definitions.d.ts +136 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +14 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +11 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +19 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +44 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +63 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +66 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/ElizaBunRuntimePlugin/BridgeInstaller.swift +94 -0
- package/ios/Sources/ElizaBunRuntimePlugin/ElizaBunRuntime.swift +705 -0
- package/ios/Sources/ElizaBunRuntimePlugin/ElizaBunRuntimePlugin.swift +1109 -0
- package/ios/Sources/ElizaBunRuntimePlugin/FullBunEngineHost.swift +677 -0
- package/ios/Sources/ElizaBunRuntimePlugin/JSContextHelpers.swift +226 -0
- package/ios/Sources/ElizaBunRuntimePlugin/SandboxPaths.swift +46 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/CryptoBridge.swift +238 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/ElizaSqliteVecBridge.m +28 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/FSBridge.swift +270 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/HTTPBridge.swift +153 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/HTTPServerBridge.swift +32 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/LlamaBridge.swift +233 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/LlamaBridgeImpl.swift +1863 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/LogBridge.swift +36 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/PathsBridge.swift +41 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/ProcessBridge.swift +80 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/SqliteBridge.swift +406 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/SqliteBridgeInstaller.swift +17 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/SqliteVecLoader.swift +66 -0
- package/ios/Sources/ElizaBunRuntimePlugin/bridge/UIBridge.swift +72 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlChinesePhonemizer.swift +313 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlConfiguration.swift +28 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlEngine.swift +325 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlHindiPhonemizer.swift +150 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlJapanesePhonemizer.swift +209 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlLatinPhonemizer.swift +374 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlModel.swift +87 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlPhonemizer.swift +679 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlPronunciationDicts.swift +131 -0
- package/ios/Sources/ElizaBunRuntimePlugin/kokoro/KokoroCoreMlSupport.swift +24 -0
- package/ios/Tests/llama-bridge-smoke-main.swift +92 -0
- package/package.json +68 -0
- package/src/bridge-contract.test.ts +127 -0
- package/src/definitions.d.ts +136 -0
- package/src/definitions.d.ts.map +1 -0
- package/src/definitions.ts +152 -0
- package/src/index.d.ts +9 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.ts +16 -0
- package/src/web.d.ts +19 -0
- package/src/web.d.ts.map +1 -0
- package/src/web.ts +80 -0
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import AVFoundation
|
|
4
|
+
|
|
5
|
+
/// Capacitor plugin shell.
|
|
6
|
+
///
|
|
7
|
+
/// Exposes the JS surface declared in `src/definitions.ts`:
|
|
8
|
+
/// - `start(opts)` — boot the runtime and load the agent bundle
|
|
9
|
+
/// - `sendMessage(opts)` — round-trip a chat message through the agent
|
|
10
|
+
/// - `getStatus()` — return ready / model / tokensPerSecond
|
|
11
|
+
/// - `stop()` — tear down the runtime
|
|
12
|
+
/// - `call({ method, args })` — invoke any `ui_register_handler` handler
|
|
13
|
+
///
|
|
14
|
+
/// The plugin delegates everything to `ElizaBunRuntime`, which owns the
|
|
15
|
+
/// JSContext on its dedicated serial dispatch queue.
|
|
16
|
+
@objc(ElizaBunRuntimePlugin)
|
|
17
|
+
public class ElizaBunRuntimePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
18
|
+
public let identifier = "ElizaBunRuntimePlugin"
|
|
19
|
+
public let jsName = "ElizaBunRuntime"
|
|
20
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
21
|
+
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "sendMessage", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
|
|
25
|
+
CAPPluginMethod(name: "getLocalTtsStatus", returnType: CAPPluginReturnPromise),
|
|
26
|
+
CAPPluginMethod(name: "getLocalTtsDiagnostics", returnType: CAPPluginReturnPromise),
|
|
27
|
+
CAPPluginMethod(name: "synthesizeLocalTts", returnType: CAPPluginReturnPromise),
|
|
28
|
+
CAPPluginMethod(name: "call", returnType: CAPPluginReturnPromise),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
// Native-only smoke is intentionally separate from the WebView smoke key.
|
|
32
|
+
// The WebView smoke exercises the production Capacitor call path; using the
|
|
33
|
+
// same key here races two full Bun runtimes and PGlite rejects the duplicate
|
|
34
|
+
// database owner.
|
|
35
|
+
private static let fullBunSmokeRequestKey = "CapacitorStorage.eliza:ios-full-bun-native-smoke:request"
|
|
36
|
+
private static let fullBunSmokeResultKey = "CapacitorStorage.eliza:ios-full-bun-native-smoke:result"
|
|
37
|
+
private static let webFullBunSmokeRequestKey = "CapacitorStorage.eliza:ios-full-bun-smoke:request"
|
|
38
|
+
private static let webFullBunSmokeResultKey = "CapacitorStorage.eliza:ios-full-bun-smoke:result"
|
|
39
|
+
private static let webFullBunPrewarmResultKey = "CapacitorStorage.eliza:ios-full-bun-prewarm:result"
|
|
40
|
+
private static let mobileRuntimeModeKey = "CapacitorStorage.eliza:mobile-runtime-mode"
|
|
41
|
+
private static let fullBunSmokeEnvKey = "ELIZA_IOS_FULL_BUN_NATIVE_SMOKE"
|
|
42
|
+
private static let webFullBunSmokeEnvKey = "ELIZA_IOS_FULL_BUN_WEB_SMOKE"
|
|
43
|
+
private var runtime: ElizaBunRuntime?
|
|
44
|
+
private var nativeSmokeStarted = false
|
|
45
|
+
private var fullBunPrewarmStarted = false
|
|
46
|
+
private var localTtsPlayers: [String: AVAudioPlayer] = [:]
|
|
47
|
+
|
|
48
|
+
override public func load() {
|
|
49
|
+
// Construct lazily on first start to avoid holding the JSVirtualMachine
|
|
50
|
+
// when the app launches without the runtime.
|
|
51
|
+
runtime = nil
|
|
52
|
+
runNativeFullBunSmokeIfRequested()
|
|
53
|
+
prewarmFullBunRuntimeIfRequested()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// MARK: - start
|
|
57
|
+
|
|
58
|
+
@objc func start(_ call: CAPPluginCall) {
|
|
59
|
+
let bundlePath = call.getString("bundlePath")
|
|
60
|
+
let polyfillPath = call.getString("polyfillPath")
|
|
61
|
+
let engine = call.getString("engine") ?? IosRuntimePolicy.defaultEngine
|
|
62
|
+
let argv = call.getArray("argv", String.self) ?? ["bun", "public/agent/agent-bundle.js"]
|
|
63
|
+
let env: [String: String]
|
|
64
|
+
if let raw = call.getObject("env") {
|
|
65
|
+
env = raw.compactMapValues { $0 as? String }
|
|
66
|
+
} else {
|
|
67
|
+
env = [:]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let startedAt = Date()
|
|
71
|
+
NSLog("[ElizaBunRuntimePlugin] start requested engine=\(engine) bundlePath=\(bundlePath ?? "default") argv=\(argv) envKeys=\(env.keys.sorted())")
|
|
72
|
+
let runtime = ensureRuntime()
|
|
73
|
+
runtime.start(
|
|
74
|
+
bundlePath: bundlePath,
|
|
75
|
+
polyfillPath: polyfillPath,
|
|
76
|
+
engine: engine,
|
|
77
|
+
argv: argv,
|
|
78
|
+
env: env
|
|
79
|
+
) { result in
|
|
80
|
+
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
|
81
|
+
switch result {
|
|
82
|
+
case .success(let outcome):
|
|
83
|
+
NSLog("[ElizaBunRuntimePlugin] start succeeded engine=\(engine) bridgeVersion=\(outcome.bridgeVersion) durationMs=\(durationMs)")
|
|
84
|
+
self.runNativeFullBunSmokeAfterSuccessfulStartIfRequested(runtime: runtime)
|
|
85
|
+
DispatchQueue.main.async {
|
|
86
|
+
call.resolve([
|
|
87
|
+
"ok": true,
|
|
88
|
+
"bridgeVersion": outcome.bridgeVersion,
|
|
89
|
+
])
|
|
90
|
+
}
|
|
91
|
+
case .failure(let error):
|
|
92
|
+
NSLog("[ElizaBunRuntimePlugin] start failed engine=\(engine) durationMs=\(durationMs) error=\(error)")
|
|
93
|
+
DispatchQueue.main.async {
|
|
94
|
+
call.resolve([
|
|
95
|
+
"ok": false,
|
|
96
|
+
"error": "\(error)",
|
|
97
|
+
])
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// MARK: - sendMessage
|
|
104
|
+
|
|
105
|
+
@objc func sendMessage(_ call: CAPPluginCall) {
|
|
106
|
+
guard let runtime = runtime else {
|
|
107
|
+
call.reject("ElizaBunRuntime is not started")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
guard let message = call.getString("message") else {
|
|
111
|
+
call.reject("sendMessage requires a message string")
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
let conversationId = call.getString("conversationId")
|
|
115
|
+
runtime.sendMessage(text: message, conversationId: conversationId) { result in
|
|
116
|
+
DispatchQueue.main.async {
|
|
117
|
+
switch result {
|
|
118
|
+
case .success(let reply):
|
|
119
|
+
call.resolve(["reply": reply])
|
|
120
|
+
case .failure(let error):
|
|
121
|
+
call.reject("\(error)")
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// MARK: - getStatus
|
|
128
|
+
|
|
129
|
+
@objc func getStatus(_ call: CAPPluginCall) {
|
|
130
|
+
guard let runtime = runtime else {
|
|
131
|
+
call.resolve(["ready": false])
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
runtime.currentStatus { status in
|
|
135
|
+
DispatchQueue.main.async {
|
|
136
|
+
var payload: JSObject = [
|
|
137
|
+
"ready": status.ready,
|
|
138
|
+
"engine": status.engine,
|
|
139
|
+
]
|
|
140
|
+
if let v = status.bridgeVersion { payload["bridgeVersion"] = v }
|
|
141
|
+
if let m = status.model { payload["model"] = m }
|
|
142
|
+
if let tps = status.tokensPerSecond { payload["tokensPerSecond"] = tps }
|
|
143
|
+
call.resolve(payload)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// MARK: - stop
|
|
149
|
+
|
|
150
|
+
@objc func stop(_ call: CAPPluginCall) {
|
|
151
|
+
guard let runtime = runtime else {
|
|
152
|
+
call.resolve()
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
runtime.stop {
|
|
156
|
+
DispatchQueue.main.async {
|
|
157
|
+
call.resolve()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@objc func getLocalTtsStatus(_ call: CAPPluginCall) {
|
|
163
|
+
#if ELIZA_IOS_INCLUDE_LLAMA
|
|
164
|
+
guard let bundle = resolveLocalTtsBundleDir(override: call.getString("bundleDir")) else {
|
|
165
|
+
call.resolve([
|
|
166
|
+
"ready": false,
|
|
167
|
+
"status": "missing",
|
|
168
|
+
"message": "Eliza-1 voice assets are not installed in this iOS build.",
|
|
169
|
+
])
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
call.resolve([
|
|
173
|
+
"ready": true,
|
|
174
|
+
"status": "assets-ready",
|
|
175
|
+
"message": "Local voice assets are installed. Voice engine will warm on first playback.",
|
|
176
|
+
"bundleDir": bundle.path,
|
|
177
|
+
"modelId": modelId(for: bundle),
|
|
178
|
+
])
|
|
179
|
+
#else
|
|
180
|
+
call.resolve([
|
|
181
|
+
"ready": false,
|
|
182
|
+
"status": "unavailable",
|
|
183
|
+
"message": "This build is missing the iOS local voice playback engine.",
|
|
184
|
+
])
|
|
185
|
+
#endif
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@objc func getLocalTtsDiagnostics(_ call: CAPPluginCall) {
|
|
189
|
+
#if ELIZA_IOS_INCLUDE_LLAMA
|
|
190
|
+
let probe = call.getBool("probe") ?? false
|
|
191
|
+
let playProbe = call.getBool("play") ?? Self.envFlag("ELIZA_IOS_TTS_PLAY_SMOKE")
|
|
192
|
+
let keepProbeAudio = call.getBool("keepAudio") ?? Self.envFlag("ELIZA_IOS_TTS_KEEP_PROBE_AUDIO")
|
|
193
|
+
let text = call.getString("text") ?? "Hi from Eliza."
|
|
194
|
+
let bundleOverride = call.getString("bundleDir")
|
|
195
|
+
let baseDiagnostics = buildLocalTtsDiagnostics(bundleOverride: bundleOverride)
|
|
196
|
+
guard probe, let bundlePath = baseDiagnostics["selectedBundleDir"] as? String else {
|
|
197
|
+
NSLog("[ElizaBunRuntimePlugin] Local TTS diagnostics \(baseDiagnostics)")
|
|
198
|
+
call.resolve(baseDiagnostics)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
202
|
+
var diagnostics = baseDiagnostics
|
|
203
|
+
let result = LlamaBridgeImpl.shared.synthesizeSpeech(
|
|
204
|
+
bundleDir: bundlePath,
|
|
205
|
+
text: text,
|
|
206
|
+
speakerPresetId: nil,
|
|
207
|
+
maxSamples: 24_000 * 20
|
|
208
|
+
)
|
|
209
|
+
var probePayload: JSObject = [
|
|
210
|
+
"ok": result.error == nil,
|
|
211
|
+
"sampleRate": result.sampleRate,
|
|
212
|
+
"samples": result.samples,
|
|
213
|
+
"durationMs": result.durationMs,
|
|
214
|
+
]
|
|
215
|
+
if let error = result.error {
|
|
216
|
+
probePayload["error"] = error
|
|
217
|
+
}
|
|
218
|
+
if let audioFilePath = result.audioFilePath, !audioFilePath.isEmpty {
|
|
219
|
+
probePayload["audioFilePath"] = audioFilePath
|
|
220
|
+
let audioFileUrl = URL(fileURLWithPath: audioFilePath)
|
|
221
|
+
if playProbe {
|
|
222
|
+
do {
|
|
223
|
+
let audioData = try Data(contentsOf: audioFileUrl)
|
|
224
|
+
try self.playLocalTtsAudio(audioData)
|
|
225
|
+
probePayload["played"] = true
|
|
226
|
+
} catch {
|
|
227
|
+
probePayload["played"] = false
|
|
228
|
+
probePayload["playError"] = error.localizedDescription
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if !keepProbeAudio {
|
|
232
|
+
try? FileManager.default.removeItem(at: audioFileUrl)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
diagnostics["probe"] = probePayload
|
|
236
|
+
diagnostics["engine"] = self.jsObject(LlamaBridgeImpl.shared.ttsEngineDiagnostics(bundleDir: bundlePath))
|
|
237
|
+
NSLog("[ElizaBunRuntimePlugin] Local TTS diagnostics \(diagnostics)")
|
|
238
|
+
DispatchQueue.main.async {
|
|
239
|
+
call.resolve(diagnostics)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
#else
|
|
243
|
+
call.resolve([
|
|
244
|
+
"available": false,
|
|
245
|
+
"message": "This build is missing the iOS local voice playback engine.",
|
|
246
|
+
])
|
|
247
|
+
#endif
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@objc func synthesizeLocalTts(_ call: CAPPluginCall) {
|
|
251
|
+
guard let text = call.getString("text"), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
252
|
+
call.reject("synthesizeLocalTts requires text")
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
#if ELIZA_IOS_INCLUDE_LLAMA
|
|
256
|
+
guard let bundle = resolveLocalTtsBundleDir(override: call.getString("bundleDir")) else {
|
|
257
|
+
call.reject("Eliza-1 voice assets are not installed in this iOS build.")
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
let speakerPresetId = call.getString("speakerPresetId")
|
|
261
|
+
?? call.getString("voice")
|
|
262
|
+
?? call.getString("voiceId")
|
|
263
|
+
let maxSamples = call.getDouble("maxSamples").map { Int($0) } ?? 24_000 * 60
|
|
264
|
+
let playImmediately = call.getBool("play") ?? false
|
|
265
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
266
|
+
let result = LlamaBridgeImpl.shared.synthesizeSpeech(
|
|
267
|
+
bundleDir: bundle.path,
|
|
268
|
+
text: text,
|
|
269
|
+
speakerPresetId: speakerPresetId,
|
|
270
|
+
maxSamples: maxSamples
|
|
271
|
+
)
|
|
272
|
+
if let error = result.error {
|
|
273
|
+
DispatchQueue.main.async {
|
|
274
|
+
call.reject(error)
|
|
275
|
+
}
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
let audioBase64: String
|
|
279
|
+
var audioDataForPlayback: Data?
|
|
280
|
+
if let audioFilePath = result.audioFilePath, !audioFilePath.isEmpty {
|
|
281
|
+
let audioFileUrl = URL(fileURLWithPath: audioFilePath)
|
|
282
|
+
do {
|
|
283
|
+
let audioData = try Data(contentsOf: audioFileUrl)
|
|
284
|
+
audioDataForPlayback = audioData
|
|
285
|
+
audioBase64 = playImmediately ? "" : audioData.base64EncodedString()
|
|
286
|
+
try? FileManager.default.removeItem(at: audioFileUrl)
|
|
287
|
+
} catch {
|
|
288
|
+
DispatchQueue.main.async {
|
|
289
|
+
call.reject("Failed to read synthesized audio: \(error.localizedDescription)")
|
|
290
|
+
}
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
audioBase64 = result.audioBase64
|
|
295
|
+
if playImmediately, let decoded = Data(base64Encoded: result.audioBase64) {
|
|
296
|
+
audioDataForPlayback = decoded
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
DispatchQueue.main.async {
|
|
300
|
+
if playImmediately {
|
|
301
|
+
do {
|
|
302
|
+
try self.playLocalTtsAudio(audioDataForPlayback)
|
|
303
|
+
} catch {
|
|
304
|
+
call.reject("Failed to play synthesized audio: \(error.localizedDescription)")
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
call.resolve([
|
|
309
|
+
"audioBase64": audioBase64,
|
|
310
|
+
"contentType": result.contentType,
|
|
311
|
+
"sampleRate": result.sampleRate,
|
|
312
|
+
"samples": result.samples,
|
|
313
|
+
"durationMs": result.durationMs,
|
|
314
|
+
"modelId": self.modelId(for: bundle),
|
|
315
|
+
"played": playImmediately,
|
|
316
|
+
])
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
#else
|
|
320
|
+
call.reject("This build is missing the iOS local voice playback engine.")
|
|
321
|
+
#endif
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func playLocalTtsAudio(_ audioData: Data?) throws {
|
|
325
|
+
guard let audioData, !audioData.isEmpty else {
|
|
326
|
+
throw NSError(
|
|
327
|
+
domain: "ElizaBunRuntime",
|
|
328
|
+
code: -40,
|
|
329
|
+
userInfo: [NSLocalizedDescriptionKey: "No synthesized audio data was available."]
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
let session = AVAudioSession.sharedInstance()
|
|
333
|
+
try session.setCategory(.playback, mode: .spokenAudio, options: [.duckOthers])
|
|
334
|
+
try session.setActive(true)
|
|
335
|
+
let player = try AVAudioPlayer(data: audioData)
|
|
336
|
+
player.prepareToPlay()
|
|
337
|
+
player.volume = 1.0
|
|
338
|
+
let playerId = UUID().uuidString
|
|
339
|
+
localTtsPlayers[playerId] = player
|
|
340
|
+
guard player.play() else {
|
|
341
|
+
localTtsPlayers.removeValue(forKey: playerId)
|
|
342
|
+
throw NSError(
|
|
343
|
+
domain: "ElizaBunRuntime",
|
|
344
|
+
code: -41,
|
|
345
|
+
userInfo: [NSLocalizedDescriptionKey: "AVAudioPlayer refused to start playback."]
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
let cleanupDelay = max(1.0, player.duration + 1.0)
|
|
349
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay) { [weak self] in
|
|
350
|
+
self?.localTtsPlayers.removeValue(forKey: playerId)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// MARK: - call
|
|
355
|
+
|
|
356
|
+
@objc func call(_ pluginCall: CAPPluginCall) {
|
|
357
|
+
guard let runtime = runtime else {
|
|
358
|
+
pluginCall.reject("ElizaBunRuntime is not started")
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
guard let method = pluginCall.getString("method") else {
|
|
362
|
+
pluginCall.reject("call requires a method name")
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
let args: Any? = pluginCall.getValue("args")
|
|
366
|
+
runtime.dispatchHandler(method: method, args: args) { (result: Result<Any?, Error>) in
|
|
367
|
+
DispatchQueue.main.async {
|
|
368
|
+
switch result {
|
|
369
|
+
case .success(let value):
|
|
370
|
+
pluginCall.resolve(["result": Self.jsonSafe(value)])
|
|
371
|
+
case .failure(let error):
|
|
372
|
+
pluginCall.reject("\(error)")
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// MARK: - Helpers
|
|
379
|
+
|
|
380
|
+
private func ensureRuntime() -> ElizaBunRuntime {
|
|
381
|
+
if let existing = runtime { return existing }
|
|
382
|
+
NSLog("[ElizaBunRuntimePlugin] creating ElizaBunRuntime")
|
|
383
|
+
let new = ElizaBunRuntime(plugin: self)
|
|
384
|
+
runtime = new
|
|
385
|
+
return new
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private struct LocalTtsBundleResolution {
|
|
389
|
+
let override: URL?
|
|
390
|
+
let roots: [URL]
|
|
391
|
+
let selectedBundle: URL?
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private func resolveLocalTtsBundleDir(override: String?) -> URL? {
|
|
395
|
+
resolveLocalTtsBundleResolution(override: override).selectedBundle
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private func resolveLocalTtsBundleResolution(override: String?) -> LocalTtsBundleResolution {
|
|
399
|
+
if let override, !override.isEmpty {
|
|
400
|
+
let url = URL(fileURLWithPath: override, isDirectory: true)
|
|
401
|
+
if hasKokoroBundle(url) {
|
|
402
|
+
return LocalTtsBundleResolution(override: url, roots: localTtsSearchRoots(), selectedBundle: url)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
let fm = FileManager.default
|
|
406
|
+
let roots = localTtsSearchRoots()
|
|
407
|
+
for root in roots where fm.fileExists(atPath: root.path) {
|
|
408
|
+
if let bundle = findKokoroBundle(in: root) {
|
|
409
|
+
return LocalTtsBundleResolution(
|
|
410
|
+
override: override.map { URL(fileURLWithPath: $0, isDirectory: true) },
|
|
411
|
+
roots: roots,
|
|
412
|
+
selectedBundle: bundle
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return LocalTtsBundleResolution(
|
|
417
|
+
override: override.map { URL(fileURLWithPath: $0, isDirectory: true) },
|
|
418
|
+
roots: roots,
|
|
419
|
+
selectedBundle: nil
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private func localTtsSearchRoots() -> [URL] {
|
|
424
|
+
let paths = SandboxPaths()
|
|
425
|
+
return [
|
|
426
|
+
paths.appSupport
|
|
427
|
+
.appendingPathComponent("local-inference", isDirectory: true)
|
|
428
|
+
.appendingPathComponent("models", isDirectory: true),
|
|
429
|
+
paths.bundle
|
|
430
|
+
.appendingPathComponent("public", isDirectory: true)
|
|
431
|
+
.appendingPathComponent("agent", isDirectory: true)
|
|
432
|
+
.appendingPathComponent("models", isDirectory: true),
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func buildLocalTtsDiagnostics(bundleOverride: String?) -> JSObject {
|
|
437
|
+
let fm = FileManager.default
|
|
438
|
+
let resolution = resolveLocalTtsBundleResolution(override: bundleOverride)
|
|
439
|
+
var payload: JSObject = [
|
|
440
|
+
"available": resolution.selectedBundle != nil,
|
|
441
|
+
"roots": resolution.roots.map { describeDirectory($0) },
|
|
442
|
+
"engine": jsObject(LlamaBridgeImpl.shared.ttsEngineDiagnostics(bundleDir: resolution.selectedBundle?.path)),
|
|
443
|
+
]
|
|
444
|
+
if let override = resolution.override {
|
|
445
|
+
payload["override"] = describeDirectory(override)
|
|
446
|
+
}
|
|
447
|
+
guard let bundle = resolution.selectedBundle else {
|
|
448
|
+
payload["message"] = "No bundled Kokoro local TTS assets were found."
|
|
449
|
+
return payload
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
payload["selectedBundleDir"] = bundle.path
|
|
453
|
+
payload["modelId"] = modelId(for: bundle)
|
|
454
|
+
payload["ttsDir"] = describeDirectory(bundle.appendingPathComponent("tts", isDirectory: true))
|
|
455
|
+
payload["files"] = [
|
|
456
|
+
"kokoroCoreMlModel": describeFile(bundle.appendingPathComponent("tts/kokoro-coreml/kokoro_5s.mlmodelc", isDirectory: true)),
|
|
457
|
+
"kokoroCoreMlVoice": describeFile(bundle.appendingPathComponent("tts/kokoro-coreml/voices/af_heart.json")),
|
|
458
|
+
"kokoroGgufModel": describeFile(bundle.appendingPathComponent("tts/kokoro/kokoro-82m-v1_0-Q4_K_M.gguf")),
|
|
459
|
+
"kokoroGgufVoice": describeFile(bundle.appendingPathComponent("tts/kokoro/voices/af_bella.bin")),
|
|
460
|
+
"legacyOmniVoiceBase": describeFile(bundle.appendingPathComponent("tts/omnivoice-base-Q4_K_M.gguf")),
|
|
461
|
+
"legacyOmniVoiceTokenizer": describeFile(bundle.appendingPathComponent("tts/omnivoice-tokenizer-Q4_K_M.gguf")),
|
|
462
|
+
"text": describeFile(bundle.appendingPathComponent("text/eliza-1-0_8b-128k.gguf")),
|
|
463
|
+
"asr": describeFile(bundle.appendingPathComponent("asr/eliza-1-asr.gguf")),
|
|
464
|
+
"manifest": describeFile(bundle.appendingPathComponent("eliza-1.manifest.json")),
|
|
465
|
+
]
|
|
466
|
+
let ttsDir = bundle.appendingPathComponent("tts", isDirectory: true)
|
|
467
|
+
if let enumerator = fm.enumerator(
|
|
468
|
+
at: ttsDir,
|
|
469
|
+
includingPropertiesForKeys: [.isRegularFileKey],
|
|
470
|
+
options: [.skipsHiddenFiles]
|
|
471
|
+
) {
|
|
472
|
+
var ggufFiles: [JSObject] = []
|
|
473
|
+
for case let url as URL in enumerator {
|
|
474
|
+
let values = try? url.resourceValues(forKeys: [.isRegularFileKey])
|
|
475
|
+
guard values?.isRegularFile == true, url.pathExtension.lowercased() == "gguf" else {
|
|
476
|
+
continue
|
|
477
|
+
}
|
|
478
|
+
ggufFiles.append(describeFile(url))
|
|
479
|
+
}
|
|
480
|
+
payload["ttsGgufFiles"] = ggufFiles
|
|
481
|
+
}
|
|
482
|
+
return payload
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private func describeDirectory(_ url: URL) -> JSObject {
|
|
486
|
+
let fm = FileManager.default
|
|
487
|
+
var isDirectory: ObjCBool = false
|
|
488
|
+
let exists = fm.fileExists(atPath: url.path, isDirectory: &isDirectory)
|
|
489
|
+
return [
|
|
490
|
+
"path": url.path,
|
|
491
|
+
"exists": exists,
|
|
492
|
+
"isDirectory": exists && isDirectory.boolValue,
|
|
493
|
+
"readable": fm.isReadableFile(atPath: url.path),
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private func describeFile(_ url: URL) -> JSObject {
|
|
498
|
+
let fm = FileManager.default
|
|
499
|
+
var payload: JSObject = [
|
|
500
|
+
"path": url.path,
|
|
501
|
+
"name": url.lastPathComponent,
|
|
502
|
+
"exists": fm.fileExists(atPath: url.path),
|
|
503
|
+
"readable": fm.isReadableFile(atPath: url.path),
|
|
504
|
+
]
|
|
505
|
+
if let attrs = try? fm.attributesOfItem(atPath: url.path),
|
|
506
|
+
let size = attrs[.size] as? NSNumber {
|
|
507
|
+
payload["bytes"] = size
|
|
508
|
+
}
|
|
509
|
+
return payload
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private static func envFlag(_ name: String) -> Bool {
|
|
513
|
+
guard let raw = ProcessInfo.processInfo.environment[name]?.lowercased() else {
|
|
514
|
+
return false
|
|
515
|
+
}
|
|
516
|
+
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private func jsObject(_ dict: [String: Any]) -> JSObject {
|
|
520
|
+
var payload: JSObject = [:]
|
|
521
|
+
for (key, value) in dict {
|
|
522
|
+
payload[key] = jsValue(value)
|
|
523
|
+
}
|
|
524
|
+
return payload
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private func jsValue(_ value: Any) -> JSValue {
|
|
528
|
+
switch value {
|
|
529
|
+
case let value as String:
|
|
530
|
+
return value
|
|
531
|
+
case let value as Bool:
|
|
532
|
+
return value
|
|
533
|
+
case let value as Int:
|
|
534
|
+
return value
|
|
535
|
+
case let value as Int64:
|
|
536
|
+
return NSNumber(value: value)
|
|
537
|
+
case let value as UInt64:
|
|
538
|
+
return NSNumber(value: value)
|
|
539
|
+
case let value as Float:
|
|
540
|
+
return value
|
|
541
|
+
case let value as Double:
|
|
542
|
+
return value
|
|
543
|
+
case let value as NSNumber:
|
|
544
|
+
return value
|
|
545
|
+
case let value as [String: Any]:
|
|
546
|
+
return jsObject(value)
|
|
547
|
+
case let value as [Any]:
|
|
548
|
+
return value.map { jsValue($0) }
|
|
549
|
+
default:
|
|
550
|
+
return String(describing: value)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private func findKokoroBundle(in root: URL) -> URL? {
|
|
555
|
+
let fm = FileManager.default
|
|
556
|
+
guard let enumerator = fm.enumerator(
|
|
557
|
+
at: root,
|
|
558
|
+
includingPropertiesForKeys: [.isDirectoryKey],
|
|
559
|
+
options: [.skipsHiddenFiles]
|
|
560
|
+
) else {
|
|
561
|
+
return nil
|
|
562
|
+
}
|
|
563
|
+
for case let url as URL in enumerator {
|
|
564
|
+
let values = try? url.resourceValues(forKeys: [.isDirectoryKey])
|
|
565
|
+
guard values?.isDirectory == true, url.pathExtension == "bundle" else {
|
|
566
|
+
continue
|
|
567
|
+
}
|
|
568
|
+
if hasKokoroBundle(url) { return url }
|
|
569
|
+
}
|
|
570
|
+
return nil
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private func hasKokoroBundle(_ bundle: URL) -> Bool {
|
|
574
|
+
let coreMlDir = bundle
|
|
575
|
+
.appendingPathComponent("tts", isDirectory: true)
|
|
576
|
+
.appendingPathComponent("kokoro-coreml", isDirectory: true)
|
|
577
|
+
let fm = FileManager.default
|
|
578
|
+
let model = coreMlDir.appendingPathComponent("kokoro_5s.mlmodelc", isDirectory: true)
|
|
579
|
+
let voice = coreMlDir
|
|
580
|
+
.appendingPathComponent("voices", isDirectory: true)
|
|
581
|
+
.appendingPathComponent("af_heart.json")
|
|
582
|
+
return fm.fileExists(atPath: model.path) && fm.fileExists(atPath: voice.path)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private func modelId(for bundle: URL) -> String {
|
|
586
|
+
bundle.deletingPathExtension().lastPathComponent
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// MARK: - Simulator full Bun smoke
|
|
590
|
+
|
|
591
|
+
private func fullBunLaunchEnvironment(isSmoke: Bool) -> [String: String] {
|
|
592
|
+
var env: [String: String] = [
|
|
593
|
+
"ELIZA_PLATFORM": "ios",
|
|
594
|
+
"ELIZA_MOBILE_PLATFORM": "ios",
|
|
595
|
+
"ELIZA_RUNTIME_MODE": IosRuntimePolicy.safeLocalExecutionMode,
|
|
596
|
+
"RUNTIME_MODE": IosRuntimePolicy.safeLocalExecutionMode,
|
|
597
|
+
"LOCAL_RUNTIME_MODE": IosRuntimePolicy.safeLocalExecutionMode,
|
|
598
|
+
"ELIZA_IOS_LOCAL_BACKEND": "1",
|
|
599
|
+
"ELIZA_IOS_BUN_STARTUP_TIMEOUT_MS": "60000",
|
|
600
|
+
"ELIZA_PGLITE_DISABLE_EXTENSIONS": "0",
|
|
601
|
+
"ELIZA_VAULT_BACKEND": "file",
|
|
602
|
+
"ELIZA_DISABLE_VAULT_PROFILE_RESOLVER": "1",
|
|
603
|
+
"ELIZA_DISABLE_AGENT_WALLET_BOOTSTRAP": "1",
|
|
604
|
+
"ELIZA_HEADLESS": "1",
|
|
605
|
+
"ELIZA_IOS_BRIDGE_TRANSPORT": "bun-host-ipc",
|
|
606
|
+
"LOG_LEVEL": "error",
|
|
607
|
+
]
|
|
608
|
+
if isSmoke {
|
|
609
|
+
env["ELIZA_IOS_FULL_BUN_SMOKE"] = "1"
|
|
610
|
+
}
|
|
611
|
+
// Pass through opt-in diagnostic toggles set on the launch environment
|
|
612
|
+
// (e.g. via `devicectl process launch --environment-variables`) so a
|
|
613
|
+
// headless prewarm boot can run the on-device model-grind self-test.
|
|
614
|
+
for key in ["ELIZA_IOS_RUN_MODEL_GRIND"] {
|
|
615
|
+
if let value = ProcessInfo.processInfo.environment[key], !value.isEmpty {
|
|
616
|
+
env[key] = value
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return IosRuntimePolicy.sanitizeEnvironment(env)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
private func prewarmFullBunRuntimeIfRequested() {
|
|
623
|
+
guard !nativeSmokeStarted, !fullBunPrewarmStarted else { return }
|
|
624
|
+
|
|
625
|
+
let defaults = UserDefaults.standard
|
|
626
|
+
let webSmokeRequested =
|
|
627
|
+
ProcessInfo.processInfo.environment[Self.webFullBunSmokeEnvKey] == "1" ||
|
|
628
|
+
defaults.string(forKey: Self.webFullBunSmokeRequestKey) == "1"
|
|
629
|
+
guard webSmokeRequested else { return }
|
|
630
|
+
|
|
631
|
+
fullBunPrewarmStarted = true
|
|
632
|
+
let runtime = ensureRuntime()
|
|
633
|
+
if webSmokeRequested {
|
|
634
|
+
writeWebFullBunSmokeProgress([
|
|
635
|
+
"phase": "native-prewarm-starting",
|
|
636
|
+
"nativePrewarm": true,
|
|
637
|
+
])
|
|
638
|
+
}
|
|
639
|
+
runtime.start(
|
|
640
|
+
bundlePath: nil,
|
|
641
|
+
polyfillPath: nil,
|
|
642
|
+
engine: "bun",
|
|
643
|
+
argv: ["bun", "--no-install", "public/agent/agent-bundle.js", "ios-bridge", "--stdio"],
|
|
644
|
+
env: fullBunLaunchEnvironment(isSmoke: webSmokeRequested)
|
|
645
|
+
) { [weak self] result in
|
|
646
|
+
guard let self = self else { return }
|
|
647
|
+
switch result {
|
|
648
|
+
case .success:
|
|
649
|
+
if webSmokeRequested {
|
|
650
|
+
self.writeWebFullBunSmokeProgress([
|
|
651
|
+
"phase": "native-prewarm-started",
|
|
652
|
+
"nativePrewarm": true,
|
|
653
|
+
])
|
|
654
|
+
self.pollWebFullBunPrewarmReady(runtime: runtime, startedAt: Date(), attempt: 0)
|
|
655
|
+
}
|
|
656
|
+
case .failure(let error):
|
|
657
|
+
self.fullBunPrewarmStarted = false
|
|
658
|
+
if webSmokeRequested {
|
|
659
|
+
self.writeWebFullBunSmokeProgress([
|
|
660
|
+
"ok": false,
|
|
661
|
+
"phase": "failed",
|
|
662
|
+
"nativePrewarm": true,
|
|
663
|
+
"error": "\(error)",
|
|
664
|
+
])
|
|
665
|
+
} else {
|
|
666
|
+
NSLog("[ElizaBunRuntime] iOS full Bun prewarm failed: \(error)")
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
private func runNativeFullBunSmokeIfRequested() {
|
|
673
|
+
guard shouldRunNativeFullBunSmoke() else { return }
|
|
674
|
+
nativeSmokeStarted = true
|
|
675
|
+
|
|
676
|
+
let runtime = ensureRuntime()
|
|
677
|
+
writeFullBunSmokeResult([
|
|
678
|
+
"phase": "native-starting",
|
|
679
|
+
"nativeOnly": true,
|
|
680
|
+
])
|
|
681
|
+
runtime.start(
|
|
682
|
+
bundlePath: nil,
|
|
683
|
+
polyfillPath: nil,
|
|
684
|
+
engine: "bun",
|
|
685
|
+
argv: ["bun", "--no-install", "public/agent/agent-bundle.js", "ios-bridge", "--stdio"],
|
|
686
|
+
env: fullBunLaunchEnvironment(isSmoke: true)
|
|
687
|
+
) { [weak self, weak runtime] result in
|
|
688
|
+
guard let self = self, let runtime = runtime else { return }
|
|
689
|
+
switch result {
|
|
690
|
+
case .success:
|
|
691
|
+
self.runNativeFullBunRouteSmoke(runtime: runtime)
|
|
692
|
+
case .failure(let error):
|
|
693
|
+
self.writeFullBunSmokeFailure(error)
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private func pollWebFullBunPrewarmReady(
|
|
699
|
+
runtime: ElizaBunRuntime,
|
|
700
|
+
startedAt: Date,
|
|
701
|
+
attempt: Int
|
|
702
|
+
) {
|
|
703
|
+
dispatchSmokeCall(runtime: runtime, method: "status", args: ["timeoutMs": 5_000]) { [weak self, weak runtime] statusResult in
|
|
704
|
+
guard let self = self, let runtime = runtime else { return }
|
|
705
|
+
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
|
706
|
+
switch statusResult {
|
|
707
|
+
case .failure(let error):
|
|
708
|
+
if elapsedMs >= 300_000 {
|
|
709
|
+
self.writeWebFullBunSmokeProgress([
|
|
710
|
+
"ok": false,
|
|
711
|
+
"phase": "failed",
|
|
712
|
+
"nativePrewarm": true,
|
|
713
|
+
"error": error.localizedDescription,
|
|
714
|
+
"finishedAt": self.isoTimestamp(),
|
|
715
|
+
])
|
|
716
|
+
return
|
|
717
|
+
}
|
|
718
|
+
self.writeWebFullBunSmokeProgress([
|
|
719
|
+
"phase": "native-prewarm-waiting-backend",
|
|
720
|
+
"nativePrewarm": true,
|
|
721
|
+
"elapsedMs": elapsedMs,
|
|
722
|
+
"attempt": attempt,
|
|
723
|
+
"lastStatusError": error.localizedDescription,
|
|
724
|
+
])
|
|
725
|
+
self.scheduleWebFullBunPrewarmReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
|
|
726
|
+
case .success(let bridgeStatus):
|
|
727
|
+
if self.isBridgeStatusReady(bridgeStatus) {
|
|
728
|
+
self.writeWebFullBunSmokeProgress([
|
|
729
|
+
"phase": "native-prewarm-ready",
|
|
730
|
+
"nativePrewarm": true,
|
|
731
|
+
"elapsedMs": elapsedMs,
|
|
732
|
+
"attempt": attempt,
|
|
733
|
+
"engine": runtime.engineMode,
|
|
734
|
+
"bridgeVersion": runtime.bridgeVersion ?? NSNull(),
|
|
735
|
+
"bridgeStatus": Self.jsonSafe(bridgeStatus),
|
|
736
|
+
])
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
if self.isBridgeStatusError(bridgeStatus) {
|
|
740
|
+
self.writeWebFullBunSmokeProgress([
|
|
741
|
+
"ok": false,
|
|
742
|
+
"phase": "failed",
|
|
743
|
+
"nativePrewarm": true,
|
|
744
|
+
"error": "iOS full Bun backend failed to boot: \(bridgeStatus ?? NSNull())",
|
|
745
|
+
"finishedAt": self.isoTimestamp(),
|
|
746
|
+
])
|
|
747
|
+
return
|
|
748
|
+
}
|
|
749
|
+
if elapsedMs >= 300_000 {
|
|
750
|
+
self.writeWebFullBunSmokeProgress([
|
|
751
|
+
"ok": false,
|
|
752
|
+
"phase": "failed",
|
|
753
|
+
"nativePrewarm": true,
|
|
754
|
+
"error": "iOS full Bun backend did not become ready within 60000ms; last status: \(bridgeStatus ?? NSNull())",
|
|
755
|
+
"finishedAt": self.isoTimestamp(),
|
|
756
|
+
])
|
|
757
|
+
return
|
|
758
|
+
}
|
|
759
|
+
self.writeWebFullBunSmokeProgress([
|
|
760
|
+
"phase": "native-prewarm-waiting-backend",
|
|
761
|
+
"nativePrewarm": true,
|
|
762
|
+
"elapsedMs": elapsedMs,
|
|
763
|
+
"attempt": attempt,
|
|
764
|
+
"bridgeStatus": Self.jsonSafe(bridgeStatus),
|
|
765
|
+
])
|
|
766
|
+
self.scheduleWebFullBunPrewarmReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private func scheduleWebFullBunPrewarmReadyPoll(
|
|
772
|
+
runtime: ElizaBunRuntime,
|
|
773
|
+
startedAt: Date,
|
|
774
|
+
attempt: Int
|
|
775
|
+
) {
|
|
776
|
+
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2.0) { [weak self, weak runtime] in
|
|
777
|
+
guard let self = self, let runtime = runtime else { return }
|
|
778
|
+
self.pollWebFullBunPrewarmReady(runtime: runtime, startedAt: startedAt, attempt: attempt)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private func runNativeFullBunSmokeAfterSuccessfulStartIfRequested(runtime: ElizaBunRuntime) {
|
|
783
|
+
guard shouldRunNativeFullBunSmoke() else { return }
|
|
784
|
+
nativeSmokeStarted = true
|
|
785
|
+
writeFullBunSmokeProgress([
|
|
786
|
+
"phase": "native-route-smoke-starting",
|
|
787
|
+
"nativeOnly": true,
|
|
788
|
+
])
|
|
789
|
+
runNativeFullBunRouteSmoke(runtime: runtime)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private func shouldRunNativeFullBunSmoke() -> Bool {
|
|
793
|
+
guard !nativeSmokeStarted else { return false }
|
|
794
|
+
if ProcessInfo.processInfo.environment[Self.fullBunSmokeEnvKey] == "1" {
|
|
795
|
+
return true
|
|
796
|
+
}
|
|
797
|
+
return UserDefaults.standard.string(forKey: Self.fullBunSmokeRequestKey) == "1"
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private func runNativeFullBunRouteSmoke(runtime: ElizaBunRuntime) {
|
|
801
|
+
pollNativeFullBunBridgeReady(runtime: runtime, startedAt: Date(), attempt: 0)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private func pollNativeFullBunBridgeReady(
|
|
805
|
+
runtime: ElizaBunRuntime,
|
|
806
|
+
startedAt: Date,
|
|
807
|
+
attempt: Int
|
|
808
|
+
) {
|
|
809
|
+
dispatchSmokeCall(runtime: runtime, method: "status", args: ["timeoutMs": 5_000]) { [weak self, weak runtime] statusResult in
|
|
810
|
+
guard let self = self, let runtime = runtime else { return }
|
|
811
|
+
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
|
812
|
+
switch statusResult {
|
|
813
|
+
case .failure(let error):
|
|
814
|
+
if elapsedMs >= 300_000 {
|
|
815
|
+
self.writeFullBunSmokeFailure(error)
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
self.writeFullBunSmokeProgress([
|
|
819
|
+
"phase": "native-waiting-backend",
|
|
820
|
+
"nativeOnly": true,
|
|
821
|
+
"elapsedMs": elapsedMs,
|
|
822
|
+
"attempt": attempt,
|
|
823
|
+
"lastStatusError": error.localizedDescription,
|
|
824
|
+
])
|
|
825
|
+
self.scheduleNativeBridgeReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
|
|
826
|
+
case .success(let bridgeStatus):
|
|
827
|
+
if self.isBridgeStatusReady(bridgeStatus) {
|
|
828
|
+
self.runNativeFullBunHealthSmoke(runtime: runtime, bridgeStatus: bridgeStatus)
|
|
829
|
+
return
|
|
830
|
+
}
|
|
831
|
+
if self.isBridgeStatusError(bridgeStatus) {
|
|
832
|
+
self.writeFullBunSmokeFailure(
|
|
833
|
+
self.makeSmokeError("native full Bun backend failed to boot: \(bridgeStatus ?? NSNull())")
|
|
834
|
+
)
|
|
835
|
+
return
|
|
836
|
+
}
|
|
837
|
+
if elapsedMs >= 300_000 {
|
|
838
|
+
self.writeFullBunSmokeFailure(
|
|
839
|
+
self.makeSmokeError("native full Bun backend did not become ready within 60000ms; last status: \(bridgeStatus ?? NSNull())")
|
|
840
|
+
)
|
|
841
|
+
return
|
|
842
|
+
}
|
|
843
|
+
self.writeFullBunSmokeProgress([
|
|
844
|
+
"phase": "native-waiting-backend",
|
|
845
|
+
"nativeOnly": true,
|
|
846
|
+
"elapsedMs": elapsedMs,
|
|
847
|
+
"attempt": attempt,
|
|
848
|
+
"bridgeStatus": Self.jsonSafe(bridgeStatus),
|
|
849
|
+
])
|
|
850
|
+
self.scheduleNativeBridgeReadyPoll(runtime: runtime, startedAt: startedAt, attempt: attempt + 1)
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private func scheduleNativeBridgeReadyPoll(
|
|
856
|
+
runtime: ElizaBunRuntime,
|
|
857
|
+
startedAt: Date,
|
|
858
|
+
attempt: Int
|
|
859
|
+
) {
|
|
860
|
+
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2.0) { [weak self, weak runtime] in
|
|
861
|
+
guard let self = self, let runtime = runtime else { return }
|
|
862
|
+
self.pollNativeFullBunBridgeReady(runtime: runtime, startedAt: startedAt, attempt: attempt)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private func runNativeFullBunHealthSmoke(runtime: ElizaBunRuntime, bridgeStatus: Any?) {
|
|
867
|
+
let healthArgs: [String: Any] = [
|
|
868
|
+
"method": "GET",
|
|
869
|
+
"path": "/api/health",
|
|
870
|
+
"headers": ["accept": "application/json"],
|
|
871
|
+
"timeoutMs": 120_000,
|
|
872
|
+
]
|
|
873
|
+
dispatchSmokeCall(runtime: runtime, method: "http_request", args: healthArgs) { [weak self, weak runtime] healthResult in
|
|
874
|
+
guard let self = self, let runtime = runtime else { return }
|
|
875
|
+
switch healthResult {
|
|
876
|
+
case .failure(let error):
|
|
877
|
+
self.writeFullBunSmokeFailure(error)
|
|
878
|
+
case .success(let healthResponse):
|
|
879
|
+
do {
|
|
880
|
+
let healthJson = try self.parseSmokeHttpJSON(
|
|
881
|
+
label: "native full Bun /api/health",
|
|
882
|
+
value: healthResponse
|
|
883
|
+
)
|
|
884
|
+
guard healthJson["ready"] as? Bool == true,
|
|
885
|
+
healthJson["runtime"] as? String == "ok" else {
|
|
886
|
+
throw self.makeSmokeError(
|
|
887
|
+
"native full Bun /api/health returned unexpected body: \(healthJson)"
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
self.createNativeSmokeConversation(
|
|
891
|
+
runtime: runtime,
|
|
892
|
+
bridgeStatus: bridgeStatus,
|
|
893
|
+
health: healthJson
|
|
894
|
+
)
|
|
895
|
+
} catch {
|
|
896
|
+
self.writeFullBunSmokeFailure(error)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private func isBridgeStatusReady(_ value: Any?) -> Bool {
|
|
903
|
+
guard let dict = value as? [String: Any] else { return false }
|
|
904
|
+
if let ready = dict["ready"] as? Bool { return ready }
|
|
905
|
+
if let ready = dict["ready"] as? NSNumber { return ready.boolValue }
|
|
906
|
+
return false
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private func isBridgeStatusError(_ value: Any?) -> Bool {
|
|
910
|
+
guard let dict = value as? [String: Any] else { return false }
|
|
911
|
+
return dict["phase"] as? String == "error"
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private func createNativeSmokeConversation(
|
|
915
|
+
runtime: ElizaBunRuntime,
|
|
916
|
+
bridgeStatus: Any?,
|
|
917
|
+
health: [String: Any]
|
|
918
|
+
) {
|
|
919
|
+
let createArgs: [String: Any] = [
|
|
920
|
+
"method": "POST",
|
|
921
|
+
"path": "/api/conversations",
|
|
922
|
+
"headers": [
|
|
923
|
+
"accept": "application/json",
|
|
924
|
+
"content-type": "application/json",
|
|
925
|
+
],
|
|
926
|
+
"body": "{\"title\":\"iOS Full Bun Native Smoke\"}",
|
|
927
|
+
"timeoutMs": 120_000,
|
|
928
|
+
]
|
|
929
|
+
dispatchSmokeCall(runtime: runtime, method: "http_request", args: createArgs) { [weak self, weak runtime] createResult in
|
|
930
|
+
guard let self = self, let runtime = runtime else { return }
|
|
931
|
+
switch createResult {
|
|
932
|
+
case .failure(let error):
|
|
933
|
+
self.writeFullBunSmokeFailure(error)
|
|
934
|
+
case .success(let createResponse):
|
|
935
|
+
do {
|
|
936
|
+
let createJson = try self.parseSmokeHttpJSON(
|
|
937
|
+
label: "native full Bun POST /api/conversations",
|
|
938
|
+
value: createResponse
|
|
939
|
+
)
|
|
940
|
+
guard let conversation = createJson["conversation"] as? [String: Any],
|
|
941
|
+
let conversationId = conversation["id"] as? String,
|
|
942
|
+
!conversationId.isEmpty else {
|
|
943
|
+
throw self.makeSmokeError("native full Bun conversation create did not return an id")
|
|
944
|
+
}
|
|
945
|
+
self.sendNativeSmokeMessage(
|
|
946
|
+
runtime: runtime,
|
|
947
|
+
bridgeStatus: bridgeStatus,
|
|
948
|
+
health: health,
|
|
949
|
+
conversationId: conversationId
|
|
950
|
+
)
|
|
951
|
+
} catch {
|
|
952
|
+
self.writeFullBunSmokeFailure(error)
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private func sendNativeSmokeMessage(
|
|
959
|
+
runtime: ElizaBunRuntime,
|
|
960
|
+
bridgeStatus: Any?,
|
|
961
|
+
health: [String: Any],
|
|
962
|
+
conversationId: String
|
|
963
|
+
) {
|
|
964
|
+
let messageArgs: [String: Any] = [
|
|
965
|
+
"message": "iOS full Bun native smoke",
|
|
966
|
+
"conversationId": conversationId,
|
|
967
|
+
"metadata": ["smoke": "ios-full-bun-native"],
|
|
968
|
+
"timeoutMs": 600_000,
|
|
969
|
+
]
|
|
970
|
+
dispatchSmokeCall(runtime: runtime, method: "send_message", args: messageArgs) { [weak self] messageResult in
|
|
971
|
+
guard let self = self else { return }
|
|
972
|
+
switch messageResult {
|
|
973
|
+
case .failure(let error):
|
|
974
|
+
self.writeFullBunSmokeFailure(error)
|
|
975
|
+
case .success(let sendMessage):
|
|
976
|
+
self.writeFullBunSmokeProgress([
|
|
977
|
+
"ok": true,
|
|
978
|
+
"phase": "native-complete",
|
|
979
|
+
"nativeOnly": true,
|
|
980
|
+
"finishedAt": self.isoTimestamp(),
|
|
981
|
+
"engine": runtime.engineMode,
|
|
982
|
+
"bridgeVersion": runtime.bridgeVersion ?? NSNull(),
|
|
983
|
+
"bridgeStatus": Self.jsonSafe(bridgeStatus),
|
|
984
|
+
"health": Self.jsonSafe(health),
|
|
985
|
+
"conversationId": conversationId,
|
|
986
|
+
"sendMessage": Self.jsonSafe(sendMessage),
|
|
987
|
+
])
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private func dispatchSmokeCall(
|
|
993
|
+
runtime: ElizaBunRuntime,
|
|
994
|
+
method: String,
|
|
995
|
+
args: Any?,
|
|
996
|
+
completion: @escaping (Result<Any?, Error>) -> Void
|
|
997
|
+
) {
|
|
998
|
+
runtime.dispatchHandler(method: method, args: args) { result in
|
|
999
|
+
completion(result)
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private func parseSmokeHttpJSON(label: String, value: Any?) throws -> [String: Any] {
|
|
1004
|
+
guard let response = value as? [String: Any] else {
|
|
1005
|
+
throw makeSmokeError("\(label) did not return an object")
|
|
1006
|
+
}
|
|
1007
|
+
let status = (response["status"] as? NSNumber)?.intValue ?? response["status"] as? Int
|
|
1008
|
+
guard let status = status, status >= 200, status < 300 else {
|
|
1009
|
+
throw makeSmokeError("\(label) returned HTTP \(String(describing: response["status"]))")
|
|
1010
|
+
}
|
|
1011
|
+
guard let body = response["body"] as? String,
|
|
1012
|
+
let data = body.data(using: .utf8),
|
|
1013
|
+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
1014
|
+
throw makeSmokeError("\(label) returned invalid JSON body")
|
|
1015
|
+
}
|
|
1016
|
+
return json
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
private func writeFullBunSmokeFailure(_ error: Error) {
|
|
1020
|
+
writeFullBunSmokeResult([
|
|
1021
|
+
"ok": false,
|
|
1022
|
+
"phase": "failed",
|
|
1023
|
+
"nativeOnly": true,
|
|
1024
|
+
"error": error.localizedDescription,
|
|
1025
|
+
"finishedAt": isoTimestamp(),
|
|
1026
|
+
])
|
|
1027
|
+
UserDefaults.standard.removeObject(forKey: Self.fullBunSmokeRequestKey)
|
|
1028
|
+
UserDefaults.standard.synchronize()
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
private func writeFullBunSmokeProgress(_ result: [String: Any]) {
|
|
1032
|
+
if let existing = UserDefaults.standard.string(forKey: Self.fullBunSmokeResultKey),
|
|
1033
|
+
let data = existing.data(using: .utf8),
|
|
1034
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
1035
|
+
json["ok"] as? Bool == true {
|
|
1036
|
+
return
|
|
1037
|
+
}
|
|
1038
|
+
writeFullBunSmokeResult(result)
|
|
1039
|
+
if result["ok"] as? Bool == true {
|
|
1040
|
+
UserDefaults.standard.removeObject(forKey: Self.fullBunSmokeRequestKey)
|
|
1041
|
+
UserDefaults.standard.synchronize()
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
private func writeFullBunSmokeResult(_ result: [String: Any]) {
|
|
1046
|
+
var payload = result
|
|
1047
|
+
payload["updatedAt"] = isoTimestamp()
|
|
1048
|
+
let safePayload = Self.jsonSafe(payload)
|
|
1049
|
+
guard JSONSerialization.isValidJSONObject(safePayload),
|
|
1050
|
+
let data = try? JSONSerialization.data(withJSONObject: safePayload),
|
|
1051
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
UserDefaults.standard.set(json, forKey: Self.fullBunSmokeResultKey)
|
|
1055
|
+
UserDefaults.standard.synchronize()
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private func writeWebFullBunSmokeProgress(_ result: [String: Any]) {
|
|
1059
|
+
var payload = result
|
|
1060
|
+
payload["updatedAt"] = isoTimestamp()
|
|
1061
|
+
let safePayload = Self.jsonSafe(payload)
|
|
1062
|
+
guard JSONSerialization.isValidJSONObject(safePayload),
|
|
1063
|
+
let data = try? JSONSerialization.data(withJSONObject: safePayload),
|
|
1064
|
+
let json = String(data: data, encoding: .utf8) else {
|
|
1065
|
+
return
|
|
1066
|
+
}
|
|
1067
|
+
UserDefaults.standard.set(json, forKey: Self.webFullBunPrewarmResultKey)
|
|
1068
|
+
UserDefaults.standard.synchronize()
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private func makeSmokeError(_ message: String) -> NSError {
|
|
1072
|
+
NSError(
|
|
1073
|
+
domain: "ElizaBunRuntimeSmoke",
|
|
1074
|
+
code: -1,
|
|
1075
|
+
userInfo: [NSLocalizedDescriptionKey: message]
|
|
1076
|
+
)
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
private func isoTimestamp() -> String {
|
|
1080
|
+
ISO8601DateFormatter().string(from: Date())
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/// Capacitor's bridge serializes a known set of Foundation types
|
|
1084
|
+
/// (`NSString`, `NSNumber`, `NSArray`, `NSDictionary`, `NSNull`). Other
|
|
1085
|
+
/// types get coerced to their string description so the React side
|
|
1086
|
+
/// always sees something.
|
|
1087
|
+
private static func jsonSafe(_ value: Any?) -> Any {
|
|
1088
|
+
guard let value = value else { return NSNull() }
|
|
1089
|
+
if value is NSNull { return NSNull() }
|
|
1090
|
+
if let s = value as? String { return s }
|
|
1091
|
+
if let n = value as? NSNumber { return n }
|
|
1092
|
+
if let arr = value as? [Any] { return arr.map { jsonSafe($0) } }
|
|
1093
|
+
if let dict = value as? [String: Any] {
|
|
1094
|
+
var out: [String: Any] = [:]
|
|
1095
|
+
for (k, v) in dict { out[k] = jsonSafe(v) }
|
|
1096
|
+
return out
|
|
1097
|
+
}
|
|
1098
|
+
return String(describing: value)
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Compatibility helper for `call.getArray<T>` typed access on older Capacitor
|
|
1103
|
+
// builds that don't expose the generic form.
|
|
1104
|
+
extension CAPPluginCall {
|
|
1105
|
+
func getArray<T>(_ key: String, _: T.Type) -> [T]? {
|
|
1106
|
+
guard let raw = self.getArray(key) else { return nil }
|
|
1107
|
+
return raw.compactMap { $0 as? T }
|
|
1108
|
+
}
|
|
1109
|
+
}
|