@dvai-bridge/ios 4.0.0 → 4.0.1

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.
Files changed (41) hide show
  1. package/Package.swift +104 -104
  2. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -23
  3. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -46
  4. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -658
  5. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -86
  6. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -33
  7. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -59
  8. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -84
  9. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -133
  10. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -164
  11. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -392
  12. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -114
  13. package/ios/Sources/DVAIBridge/License/Types.swift +195 -195
  14. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -118
  15. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -34
  16. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -19
  17. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -123
  18. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -130
  19. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -137
  20. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -108
  21. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -96
  22. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -69
  23. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -53
  24. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -18
  25. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -11
  26. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -32
  27. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -41
  28. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -40
  29. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -19
  30. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -37
  31. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -52
  32. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -33
  33. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -658
  34. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -69
  35. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -25
  36. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -45
  37. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +385 -359
  38. package/package.json +3 -4
  39. package/DVAIBridge.podspec +0 -120
  40. package/LICENSE +0 -51
  41. package/README.md +0 -199
@@ -1,658 +1,658 @@
1
- import Foundation
2
- import Combine
3
- #if canImport(UIKit)
4
- import UIKit
5
- #endif
6
- #if !COCOAPODS
7
- import DVAILlamaCore
8
- #endif
9
- #if !COCOAPODS
10
- import DVAIFoundationCore
11
- #endif
12
- #if !COCOAPODS
13
- import DVAICoreMLCore
14
- #endif
15
- #if !COCOAPODS
16
- import DVAIMLXCore
17
- #endif
18
-
19
- /// The iOS SDK entry-point. Use the `shared` singleton or construct an instance
20
- /// for test isolation. All methods are async-throws and dispatch to the active
21
- /// backend's PluginState under the hood. Capacitor-free: no Capacitor headers
22
- /// are imported anywhere.
23
- public actor DVAIBridge {
24
- public static let shared = DVAIBridge()
25
-
26
- /// Active backend handle. The CoreML state is stored as `Any` so this
27
- /// enum itself doesn't need an `@available` gate (the package's macOS
28
- /// floor is .v14 but `CoreMLPluginState` requires macOS 15). All access
29
- /// to the CoreML state happens inside `if #available(macOS 15.0, *)`.
30
- private enum BackendInstance {
31
- case llama(PluginState)
32
- #if !COCOAPODS
33
- // Foundation backend uses Apple's `FoundationModels` (iOS 26+),
34
- // whose import emits implicit autolink directives for private
35
- // frameworks (`SwiftUICore`, `UIUtilities`, `CoreAudioTypes`)
36
- // that non-Apple products cannot link directly. Under SwiftPM
37
- // the consumer's app target IS an allowed client of those
38
- // frameworks, so the link succeeds; under CocoaPods the link
39
- // happens inside the pod's framework target, which isn't.
40
- // Excluded here; selecting `.foundation` at runtime under a
41
- // CocoaPods build throws DVAIBridgeError.backendUnavailable.
42
- case foundation(FoundationPluginState)
43
- #endif
44
- case coreml(Any)
45
- #if !COCOAPODS
46
- // MLX backend uses mlx-swift-lm which depends on Apple's MLX
47
- // Swift framework. Same single-module-CocoaPods autolink concern
48
- // as Foundation; gated SwiftPM-only.
49
- case mlx(MLXPluginState)
50
- #endif
51
- }
52
-
53
- private var active: BackendInstance?
54
- private var activeKind: BackendKind?
55
- private var activeBaseUrl: String?
56
- private var offloadRuntime: Any? // type-erased; gated by availability
57
- /// v3.2 — pre-routing proxy in front of the native backend when
58
- /// offload is enabled. Owns the public `BoundServer.baseUrl`.
59
- private var offloadProxy: Any? // OffloadProxy; type-erased for availability
60
- /// v3.2 — set true when the precheck classified this device as
61
- /// `tooWeak` or `offloadOnly`. In that mode no model is loaded;
62
- /// the proxy stands alone and forwards every request to a peer.
63
- public private(set) var offloadOnlyMode: Bool = false
64
- private let downloader = ModelDownloader()
65
- internal let progressBroadcaster = ProgressBroadcaster()
66
-
67
- public init() {}
68
-
69
- // MARK: - v3.2 — Hardware assessment (data, not UI)
70
-
71
- /// v3.2 — pre-init hardware assessment.
72
- ///
73
- /// Returns a JSON-serializable description of how this device would
74
- /// handle local inference, BEFORE any model download/load. The SDK
75
- /// itself never shows UI for hardware decisions — consumer apps
76
- /// call this and decide their own UX based on the returned `mode`:
77
- ///
78
- /// - `.ok` → device can comfortably run the model
79
- /// locally; `start()` proceeds normally.
80
- /// - `.offloadOnly` → device can run but slowly (below
81
- /// `OffloadConfig.minLocalCapability`);
82
- /// `start()` skips the model load and routes
83
- /// every request to a paired peer.
84
- /// - `.tooWeak` → device is below the hardware floor (3
85
- /// tok/s by default); `start()` ALSO skips
86
- /// the model load. Consumers typically bail
87
- /// rather than even calling `start()`.
88
- ///
89
- /// The result is `Codable` so it round-trips cleanly through
90
- /// Capacitor / React Native / Pigeon bridges as JSON.
91
- public nonisolated func assessHardware(
92
- hardwareMinimum: Double = 3.0,
93
- minLocalCapability: Double = 10.0
94
- ) -> HardwareAssessment {
95
- let result = CapabilityPrecheck.assess(
96
- thresholds: CapabilityPrecheck.Thresholds(
97
- hardwareMinimum: hardwareMinimum,
98
- minLocalCapability: minLocalCapability
99
- )
100
- )
101
- return HardwareAssessment(from: result)
102
- }
103
-
104
- // MARK: - Lifecycle
105
-
106
- /// v3.0+ surface: start with `StartOptions` (carries optional
107
- /// `OffloadConfig`).
108
- ///
109
- /// v3.2 lifecycle:
110
- /// - if `offload.enabled == true`, run the pre-init capability
111
- /// gate (`assessHardware`) before any backend init.
112
- /// - if the precheck returns `tooWeak` / `offloadOnly`, skip
113
- /// backend init entirely (`offloadOnlyMode = true`); only the
114
- /// OffloadRuntime + OffloadProxy come up. Every chat request
115
- /// forwards to a paired peer.
116
- /// - otherwise the inner `start(_ config:)` runs normally with
117
- /// the backend on `httpBasePort + 100` (internal). The
118
- /// OffloadProxy binds the user-facing `httpBasePort` and
119
- /// decides per-request whether to forward locally or to a
120
- /// peer.
121
- ///
122
- /// For v2.x backwards compat (no offload): the inner
123
- /// `start(_ config:)` overload behaves exactly as before.
124
- public func start(_ options: StartOptions) async throws -> BoundServer {
125
- offloadOnlyMode = false
126
-
127
- // v3.2.2 — License gate. Run BEFORE any backend init / port
128
- // binding / model load: there's no point spending the resources
129
- // if the SDK is going to refuse to operate.
130
- //
131
- // - DEBUG / simulator / DVAI_FORCE_DEV=1 → free-dev, proceed.
132
- // - commercial / trial → proceed, attach to BoundServer.
133
- // - free-prod / free-expired (production w/o license) → throws
134
- // `LicenseRequiredError` (BSL 1.1 enforcement point).
135
- let validator = LicenseValidator(options: LicenseValidatorOptions(
136
- token: options.licenseToken,
137
- path: options.licenseKeyPath
138
- ))
139
- let licenseStatus = try await validator.validateAndAssert()
140
-
141
- let isOffloadEnabled = options.offload?.enabled == true
142
- if isOffloadEnabled {
143
- let assessment = assessHardware(
144
- hardwareMinimum: 3.0,
145
- minLocalCapability: options.offload!.minLocalCapability
146
- )
147
- offloadOnlyMode = (assessment.mode == .tooWeak || assessment.mode == .offloadOnly)
148
- progressBroadcaster.emit(ProgressEvent(
149
- phase: .load,
150
- message: "[DVAI/precheck] \(assessment.mode.rawValue): \(assessment.reason)"
151
- ))
152
- }
153
-
154
- // Determine the backend's internal port. When the proxy is in
155
- // front, shift the backend off the user-facing port to avoid
156
- // collision: backend at httpBasePort + 100, proxy at httpBasePort.
157
- let userPort = options.config.httpBasePort
158
- let backendOpts: DVAIBridgeConfig
159
- if isOffloadEnabled && !offloadOnlyMode {
160
- backendOpts = options.config.with(httpBasePort: userPort + 100)
161
- } else {
162
- backendOpts = options.config
163
- }
164
-
165
- let backendServer: BoundServer? = offloadOnlyMode
166
- ? nil
167
- : try await start(backendOpts)
168
-
169
- // Bring up offload runtime + proxy when offload is enabled.
170
- if isOffloadEnabled, let offload = options.offload {
171
- if #available(iOS 14.0, macOS 11.0, *) {
172
- let runtime = try OffloadRuntime(config: offload)
173
- // OffloadRuntime.start expects a BoundServer for the
174
- // advertiser's `port`. Use a synthetic one in offload-only
175
- // mode (port = userPort, the proxy's port).
176
- let resolvedBackend = try BackendSelector.resolve(options.config.backend, config: options.config)
177
- let serverForRuntime = backendServer ?? BoundServer(
178
- baseUrl: "http://127.0.0.1:\(userPort)",
179
- port: userPort,
180
- backend: resolvedBackend,
181
- modelId: ""
182
- )
183
- try await runtime.start(
184
- boundServer: serverForRuntime,
185
- libraryVersion: DVAIBridgeVersion.current
186
- )
187
- self.offloadRuntime = runtime
188
-
189
- // Spin up the OffloadProxy in front of the backend.
190
- let deviceId = (try? runtime.deviceIDStore.get()) ?? "unknown"
191
- let proxy = OffloadProxy(
192
- backendBaseUrl: backendServer?.baseUrl,
193
- offloadConfig: offload,
194
- pairingPolicy: runtime.pairingPolicy,
195
- peerProvider: { [weak runtime] in
196
- guard let runtime else { return [] }
197
- return await runtime.discovery.peers()
198
- },
199
- appId: "co.deepvoiceai.dvai-bridge",
200
- selfDeviceId: deviceId
201
- )
202
- let boundProxyPort = try await proxy.start(basePort: userPort, maxAttempts: 16)
203
- self.offloadProxy = proxy
204
-
205
- let proxyServer = BoundServer(
206
- baseUrl: "http://127.0.0.1:\(boundProxyPort)",
207
- port: boundProxyPort,
208
- backend: resolvedBackend,
209
- modelId: backendServer?.modelId ?? "",
210
- licenseStatus: licenseStatus
211
- )
212
- self.activeBaseUrl = proxyServer.baseUrl
213
- if active == nil {
214
- activeKind = proxyServer.backend
215
- }
216
- return proxyServer
217
- }
218
- }
219
-
220
- return backendServer!.with(licenseStatus: licenseStatus)
221
- }
222
-
223
- /// Legacy v2.x entry point — takes a bare `DVAIBridgeConfig` with no
224
- /// offload / license knobs. Internally called by the
225
- /// `start(_ options:)` overload AFTER the license gate has already
226
- /// run, so this method does NOT re-run validation. Callers that
227
- /// hand-call `start(_ config:)` directly (no `StartOptions`) are
228
- /// responsible for invoking `LicenseValidator().validateAndAssert()`
229
- /// themselves if they need BSL 1.1 enforcement.
230
- public func start(_ config: DVAIBridgeConfig) async throws -> BoundServer {
231
- if let activeBaseUrl, let activeKind {
232
- throw DVAIBridgeError.alreadyStarted(currentBackend: activeKind, baseUrl: activeBaseUrl)
233
- }
234
-
235
- let resolved = try BackendSelector.resolve(config.backend, config: config)
236
- let opts = config.toCoreOpts()
237
-
238
- let result: [String: Any]
239
- let backend: BackendInstance
240
-
241
- progressBroadcaster.emit(ProgressEvent(phase: .load))
242
-
243
- switch resolved {
244
- case .auto:
245
- // BackendSelector.resolve never returns .auto; keep the compiler happy
246
- throw DVAIBridgeError.configurationInvalid(reason: "BackendSelector returned .auto unexpectedly")
247
- case .llama:
248
- let state = PluginState()
249
- do {
250
- result = try await state.start(opts: opts)
251
- } catch {
252
- progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
253
- throw DVAIBridgeError.modelLoadFailed(reason: error.localizedDescription)
254
- }
255
- backend = .llama(state)
256
- case .foundation:
257
- #if !COCOAPODS
258
- let state = FoundationPluginState()
259
- do {
260
- result = try await state.start(opts: opts)
261
- } catch {
262
- progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
263
- throw DVAIBridgeError.backendError(underlying: error.localizedDescription)
264
- }
265
- backend = .foundation(state)
266
- #else
267
- throw DVAIBridgeError.backendUnavailable(
268
- .foundation,
269
- reason: "Foundation Models backend is not available in CocoaPods builds of dvai-bridge — Apple's FoundationModels framework triggers private-framework autolink directives that CocoaPods consumers cannot link. Use SwiftPM if your app needs the Foundation backend, or use .llama or .coreml instead."
270
- )
271
- #endif
272
- case .coreml:
273
- // iOS 18.1 floor of this package already satisfies CoreMLPluginState's
274
- // iOS 18.0 requirement, but macOS 14 (the package floor) does not
275
- // satisfy its macOS 15.0 requirement — gate explicitly.
276
- if #available(macOS 15.0, *) {
277
- let state = CoreMLPluginState()
278
- do {
279
- result = try await state.start(opts: opts)
280
- } catch {
281
- progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
282
- throw DVAIBridgeError.backendUnavailable(.coreml, reason: error.localizedDescription)
283
- }
284
- backend = .coreml(state)
285
- } else {
286
- throw DVAIBridgeError.backendUnavailable(.coreml, reason: "Requires macOS 15+")
287
- }
288
- case .mlx:
289
- #if !COCOAPODS
290
- let state = MLXPluginState()
291
- do {
292
- result = try await state.start(opts: opts)
293
- } catch {
294
- progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
295
- throw DVAIBridgeError.backendUnavailable(.mlx, reason: error.localizedDescription)
296
- }
297
- backend = .mlx(state)
298
- #else
299
- throw DVAIBridgeError.backendUnavailable(
300
- .mlx,
301
- reason: "MLX backend is not available in CocoaPods builds of dvai-bridge — mlx-swift-lm's transitive dependencies don't publish CocoaPods specs. Use SwiftPM if your app needs the MLX backend, or use .llama or .coreml instead."
302
- )
303
- #endif
304
- }
305
-
306
- let server = try BoundServer(coreResult: result, backend: resolved)
307
- self.active = backend
308
- self.activeKind = resolved
309
- self.activeBaseUrl = server.baseUrl
310
-
311
- progressBroadcaster.emit(ProgressEvent(phase: .ready))
312
-
313
- let serverCopy = server
314
- await MainActor.run {
315
- DVAIBridgeReactiveStateRegistry.shared.state(for: self).didStart(serverCopy)
316
- }
317
- return server
318
- }
319
-
320
- public func stop() async throws {
321
- // v3.2 — tear down the proxy first so consumer requests stop
322
- // arriving before we drop the backend; then stop the offload
323
- // runtime (discovery + advertiser) before the backend dies.
324
- if #available(iOS 14.0, macOS 11.0, *) {
325
- if let proxy = offloadProxy as? OffloadProxy {
326
- await proxy.stop()
327
- }
328
- }
329
- offloadProxy = nil
330
- offloadOnlyMode = false
331
-
332
- // Tear down the offload runtime — it depends on the bound
333
- // server being up while we stop discovery cleanly.
334
- if #available(iOS 14.0, macOS 11.0, *) {
335
- if let runtime = offloadRuntime as? OffloadRuntime {
336
- await runtime.stop()
337
- }
338
- }
339
- offloadRuntime = nil
340
-
341
- guard let backend = active else {
342
- return // idempotent
343
- }
344
- do {
345
- switch backend {
346
- case .llama(let state):
347
- try await state.stop()
348
- #if !COCOAPODS
349
- case .foundation(let state):
350
- try await state.stop()
351
- #endif
352
- case .coreml(let any):
353
- // Always gated — macOS 14 can never have stored a coreml state
354
- // here (start() rejects it), so this branch is unreachable on
355
- // pre-15 macOS, but the availability check is required by the
356
- // type system.
357
- if #available(macOS 15.0, *) {
358
- if let state = any as? CoreMLPluginState {
359
- try await state.stop()
360
- }
361
- }
362
- #if !COCOAPODS
363
- case .mlx(let state):
364
- try await state.stop()
365
- #endif
366
- }
367
- } catch {
368
- // Even if stop() throws, clear state — caller can't usefully retry
369
- self.active = nil
370
- self.activeKind = nil
371
- self.activeBaseUrl = nil
372
- await MainActor.run {
373
- DVAIBridgeReactiveStateRegistry.shared.state(for: self).didStop()
374
- }
375
- throw DVAIBridgeError.backendError(underlying: error.localizedDescription)
376
- }
377
- self.active = nil
378
- self.activeKind = nil
379
- self.activeBaseUrl = nil
380
- await MainActor.run {
381
- DVAIBridgeReactiveStateRegistry.shared.state(for: self).didStop()
382
- }
383
- }
384
-
385
- // MARK: - Status
386
-
387
- public struct StatusInfo: Sendable, Equatable {
388
- public let running: Bool
389
- public let backend: BackendKind?
390
- public let baseUrl: String?
391
- }
392
-
393
- public func status() -> StatusInfo {
394
- StatusInfo(
395
- running: active != nil,
396
- backend: activeKind,
397
- baseUrl: activeBaseUrl
398
- )
399
- }
400
-
401
- // MARK: - Progress observation
402
-
403
- public nonisolated var progressPublisher: AnyPublisher<ProgressEvent, Never> {
404
- progressBroadcaster.publisher
405
- }
406
-
407
- public nonisolated var progressStream: AsyncStream<ProgressEvent> {
408
- progressBroadcaster.makeStream()
409
- }
410
-
411
- @discardableResult
412
- public nonisolated func addProgressListener(
413
- _ cb: @escaping @Sendable (ProgressEvent) -> Void
414
- ) -> CancellationToken {
415
- progressBroadcaster.addCallback(cb)
416
- }
417
-
418
- // MARK: - Model management (delegates to ModelDownloader)
419
-
420
- public struct DownloadOptions: Sendable {
421
- public var url: URL
422
- public var sha256: String
423
- public var destFilename: String?
424
- public var headers: [String: String]
425
- public init(url: URL, sha256: String, destFilename: String? = nil, headers: [String: String] = [:]) {
426
- self.url = url
427
- self.sha256 = sha256
428
- self.destFilename = destFilename
429
- self.headers = headers
430
- }
431
- }
432
-
433
- public struct DownloadResult: Sendable, Equatable {
434
- public let path: String
435
- public let cached: Bool
436
- public init(path: String, cached: Bool) {
437
- self.path = path
438
- self.cached = cached
439
- }
440
- }
441
-
442
- public func downloadModel(_ opts: DownloadOptions) async throws -> DownloadResult {
443
- let dest = opts.destFilename ?? opts.url.lastPathComponent
444
- progressBroadcaster.emit(ProgressEvent(phase: .download))
445
- do {
446
- let coreResult = try await downloader.downloadModel(
447
- url: opts.url,
448
- expectedSha256: opts.sha256,
449
- destFilename: dest,
450
- headers: opts.headers,
451
- onProgress: { [weak self] (received: Int64, total: Int64?) in
452
- let percent: Double? = total.flatMap { $0 > 0 ? (Double(received) / Double($0)) * 100.0 : nil }
453
- self?.progressBroadcaster.emit(ProgressEvent(
454
- phase: .download,
455
- bytesReceived: received,
456
- bytesTotal: total,
457
- percent: percent
458
- ))
459
- }
460
- )
461
- progressBroadcaster.emit(ProgressEvent(phase: .verify))
462
- return DownloadResult(path: coreResult.path, cached: coreResult.cached)
463
- } catch {
464
- progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
465
- if case ModelDownloader.DownloadError.checksumMismatch = error {
466
- throw DVAIBridgeError.checksumMismatch
467
- }
468
- throw DVAIBridgeError.downloadFailed(reason: error.localizedDescription)
469
- }
470
- }
471
-
472
- public func listCachedModels() async throws -> [CachedModelInfoSwift] {
473
- try await downloader.listCachedModels()
474
- }
475
-
476
- public func deleteCachedModel(filename: String) async throws {
477
- try await downloader.deleteCachedModel(filename: filename)
478
- }
479
-
480
- public func cacheDir() async throws -> String {
481
- try await downloader.cacheDirPath()
482
- }
483
-
484
- // MARK: - Offload (v3.0)
485
-
486
- /// AsyncStream of incoming pairing requests. The host app awaits
487
- /// `for await req in await DVAIBridge.shared.pairingRequests()` and
488
- /// calls `req.respond(approved:)` to approve or deny each one.
489
- ///
490
- /// Returns an empty (immediately-finished) stream when offload isn't
491
- /// enabled or hasn't been started yet.
492
- public func pairingRequests() -> AsyncStream<PairingRequest> {
493
- if #available(iOS 14.0, macOS 11.0, *) {
494
- if let runtime = self.offloadRuntime as? OffloadRuntime {
495
- return runtime.pairingRequestStream()
496
- }
497
- }
498
- return Self.emptyStream()
499
- }
500
-
501
- /// AsyncStream of LAN-discovery events (peer-up / peer-down).
502
- /// Empty if offload isn't enabled.
503
- @available(iOS 14.0, macOS 11.0, *)
504
- public func discoveryEvents() -> AsyncStream<NWBrowserDiscovery.Event> {
505
- if let runtime = self.offloadRuntime as? OffloadRuntime {
506
- return runtime.discoveryEventStream()
507
- }
508
- return AsyncStream { continuation in
509
- continuation.finish()
510
- }
511
- }
512
-
513
- /// Stable per-install device ID for THIS device. Generated on
514
- /// first call and persisted under
515
- /// `<Application Support>/dvai-bridge/device-id`. Used by host
516
- /// apps to filter the iPhone's own `_dvai-bridge._tcp`
517
- /// advertisement out of `discoveryEvents()` (NWBrowser surfaces
518
- /// the local device's own service alongside remote peers — match
519
- /// against this id to drop the self-loop).
520
- ///
521
- /// - Throws if offload isn't enabled / start() hasn't run.
522
- @available(iOS 14.0, macOS 11.0, *)
523
- public func deviceId() async throws -> String {
524
- guard let runtime = self.offloadRuntime as? OffloadRuntime else {
525
- throw DVAIBridgeError.configurationInvalid(reason:
526
- "deviceId requires offload to be enabled and start() to have been called.")
527
- }
528
- return try runtime.deviceIDStore.get()
529
- }
530
-
531
- /// v3.2.1 — initiate a LAN pairing handshake against a discovered
532
- /// peer. POSTs `/v1/dvai/handshake` to `peer.baseUrl` with our
533
- /// device identity in the body; on the peer's approval, persists
534
- /// the returned pairing key to our local `PairingStore` so future
535
- /// offload requests to that peer get HMAC-signed.
536
- ///
537
- /// Wire-compatible with the TS-side `handleHandshake` in
538
- /// `packages/dvai-bridge-core/src/handlers/dvai/index.ts` AND the
539
- /// matching iOS-side handler that ships with this release in
540
- /// `OffloadProxy.handleHandshakeRequest`.
541
- ///
542
- /// - Throws `DVAIBridgeError.configurationInvalid` if offload
543
- /// isn't started, or the peer rejects the handshake (HTTP 4xx),
544
- /// or the response body is malformed.
545
- /// - Throws underlying URLSession errors on transport failure.
546
- @available(iOS 14.0, macOS 11.0, *)
547
- public func initiatePairing(with peer: MDNSPeer) async throws -> Pairing {
548
- guard let runtime = self.offloadRuntime as? OffloadRuntime else {
549
- throw DVAIBridgeError.configurationInvalid(reason:
550
- "initiatePairing requires offload to be enabled and start() to have been called.")
551
- }
552
-
553
- // Identity of THIS device — what we send to the peer so it
554
- // knows who's asking. The peer's UI surfaces these strings
555
- // in its approval prompt.
556
- let selfDeviceId = try runtime.deviceIDStore.get()
557
- let selfDeviceName = await Self.resolveSelfName()
558
-
559
- // peer.baseUrl already ends in `/v1` (NWBrowserDiscovery
560
- // synthesises it as `<scheme>://<host>:<port>/v1`). Strip
561
- // that trailing segment before appending `/v1/dvai/handshake`,
562
- // otherwise the URL becomes `…/v1/v1/dvai/handshake` and the
563
- // peer 404s.
564
- let trimmedBase = peer.baseUrl.hasSuffix("/v1")
565
- ? String(peer.baseUrl.dropLast("/v1".count))
566
- : peer.baseUrl
567
- guard let url = URL(string: trimmedBase + "/v1/dvai/handshake") else {
568
- throw DVAIBridgeError.configurationInvalid(reason:
569
- "[DVAI/pairing] could not construct handshake URL from baseUrl=\(peer.baseUrl)")
570
- }
571
- var req = URLRequest(url: url)
572
- req.httpMethod = "POST"
573
- req.setValue("application/json", forHTTPHeaderField: "Content-Type")
574
- let bodyDict: [String: Any] = [
575
- "peerDeviceId": selfDeviceId,
576
- "peerDeviceName": selfDeviceName,
577
- "via": "lan-handshake",
578
- "appId": "co.deepvoiceai.dvai-bridge",
579
- ]
580
- req.httpBody = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
581
- req.timeoutInterval = 60.0 // matches the iOS pairing-policy default
582
-
583
- let session = URLSession(configuration: .ephemeral)
584
- let (data, response) = try await session.data(for: req)
585
- guard let http = response as? HTTPURLResponse else {
586
- throw DVAIBridgeError.configurationInvalid(reason: "[DVAI/pairing] non-HTTP response from peer")
587
- }
588
- if http.statusCode != 200 {
589
- let detail = String(data: data, encoding: .utf8) ?? "<no body>"
590
- throw DVAIBridgeError.configurationInvalid(reason:
591
- "[DVAI/pairing] peer rejected handshake (HTTP \(http.statusCode)): \(detail)")
592
- }
593
-
594
- // Response shape mirrors the TS handler:
595
- // { paired: true, pairedAt, via, pairingKey, peerDeviceId }
596
- guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
597
- json["paired"] as? Bool == true,
598
- let pairingKey = json["pairingKey"] as? String,
599
- let peerDeviceIdResp = json["peerDeviceId"] as? String,
600
- let pairedAt = (json["pairedAt"] as? Int64)
601
- ?? (json["pairedAt"] as? Int).map(Int64.init) else {
602
- throw DVAIBridgeError.configurationInvalid(reason:
603
- "[DVAI/pairing] malformed handshake response from peer")
604
- }
605
- let viaRaw = (json["via"] as? String) ?? "lan-handshake"
606
- let via = Pairing.Via(rawValue: viaRaw) ?? .lanHandshake
607
-
608
- let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
609
- let pairing = Pairing(
610
- peerDeviceId: peerDeviceIdResp,
611
- peerDeviceName: peer.deviceName,
612
- pairingKey: pairingKey,
613
- pairedAt: pairedAt,
614
- lastUsedAt: nowMs,
615
- via: via,
616
- // v3.2.1 — capture the peer's baseUrl so the OffloadProxy
617
- // can route here even when the peer isn't currently in
618
- // mDNS discovery (the macOS Hub case). We store the
619
- // ORIGINAL baseUrl supplied to this method, not the
620
- // possibly-mutated handshake URL — `peer.baseUrl` is the
621
- // OpenAI-API root (`<scheme>://<host>:<port>/v1`) the
622
- // OffloadProxy can forward chat requests to.
623
- baseUrl: peer.baseUrl
624
- )
625
- try await runtime.pairingStore.set(pairing)
626
- return pairing
627
- }
628
-
629
- /// Best-effort device-name lookup used by `initiatePairing`. iOS
630
- /// blocks `UIDevice.current.name` off the main thread; fall back
631
- /// to the host name when off-main.
632
- private static func resolveSelfName() async -> String {
633
- #if canImport(UIKit) && !os(macOS)
634
- return await MainActor.run { UIDevice.current.name }
635
- #else
636
- return ProcessInfo.processInfo.hostName
637
- #endif
638
- }
639
-
640
- private static func emptyStream<T: Sendable>() -> AsyncStream<T> {
641
- AsyncStream<T> { continuation in
642
- continuation.finish()
643
- }
644
- }
645
-
646
- /// Test-only accessor for the current offload runtime, if any.
647
- /// Returns nil when offload isn't enabled or `start()` hasn't run.
648
- @available(iOS 14.0, macOS 11.0, *)
649
- internal func _testOffloadRuntime() -> OffloadRuntime? {
650
- offloadRuntime as? OffloadRuntime
651
- }
652
- }
653
-
654
- /// Library SemVer constant — keep in sync with the package's published
655
- /// version. Used by the mDNS advertiser TXT record.
656
- public enum DVAIBridgeVersion {
657
- public static let current = "4.0.0"
658
- }
1
+ import Foundation
2
+ import Combine
3
+ #if canImport(UIKit)
4
+ import UIKit
5
+ #endif
6
+ #if !COCOAPODS
7
+ import DVAILlamaCore
8
+ #endif
9
+ #if !COCOAPODS
10
+ import DVAIFoundationCore
11
+ #endif
12
+ #if !COCOAPODS
13
+ import DVAICoreMLCore
14
+ #endif
15
+ #if !COCOAPODS
16
+ import DVAIMLXCore
17
+ #endif
18
+
19
+ /// The iOS SDK entry-point. Use the `shared` singleton or construct an instance
20
+ /// for test isolation. All methods are async-throws and dispatch to the active
21
+ /// backend's PluginState under the hood. Capacitor-free: no Capacitor headers
22
+ /// are imported anywhere.
23
+ public actor DVAIBridge {
24
+ public static let shared = DVAIBridge()
25
+
26
+ /// Active backend handle. The CoreML state is stored as `Any` so this
27
+ /// enum itself doesn't need an `@available` gate (the package's macOS
28
+ /// floor is .v14 but `CoreMLPluginState` requires macOS 15). All access
29
+ /// to the CoreML state happens inside `if #available(macOS 15.0, *)`.
30
+ private enum BackendInstance {
31
+ case llama(PluginState)
32
+ #if !COCOAPODS
33
+ // Foundation backend uses Apple's `FoundationModels` (iOS 26+),
34
+ // whose import emits implicit autolink directives for private
35
+ // frameworks (`SwiftUICore`, `UIUtilities`, `CoreAudioTypes`)
36
+ // that non-Apple products cannot link directly. Under SwiftPM
37
+ // the consumer's app target IS an allowed client of those
38
+ // frameworks, so the link succeeds; under CocoaPods the link
39
+ // happens inside the pod's framework target, which isn't.
40
+ // Excluded here; selecting `.foundation` at runtime under a
41
+ // CocoaPods build throws DVAIBridgeError.backendUnavailable.
42
+ case foundation(FoundationPluginState)
43
+ #endif
44
+ case coreml(Any)
45
+ #if !COCOAPODS
46
+ // MLX backend uses mlx-swift-lm which depends on Apple's MLX
47
+ // Swift framework. Same single-module-CocoaPods autolink concern
48
+ // as Foundation; gated SwiftPM-only.
49
+ case mlx(MLXPluginState)
50
+ #endif
51
+ }
52
+
53
+ private var active: BackendInstance?
54
+ private var activeKind: BackendKind?
55
+ private var activeBaseUrl: String?
56
+ private var offloadRuntime: Any? // type-erased; gated by availability
57
+ /// v3.2 — pre-routing proxy in front of the native backend when
58
+ /// offload is enabled. Owns the public `BoundServer.baseUrl`.
59
+ private var offloadProxy: Any? // OffloadProxy; type-erased for availability
60
+ /// v3.2 — set true when the precheck classified this device as
61
+ /// `tooWeak` or `offloadOnly`. In that mode no model is loaded;
62
+ /// the proxy stands alone and forwards every request to a peer.
63
+ public private(set) var offloadOnlyMode: Bool = false
64
+ private let downloader = ModelDownloader()
65
+ internal let progressBroadcaster = ProgressBroadcaster()
66
+
67
+ public init() {}
68
+
69
+ // MARK: - v3.2 — Hardware assessment (data, not UI)
70
+
71
+ /// v3.2 — pre-init hardware assessment.
72
+ ///
73
+ /// Returns a JSON-serializable description of how this device would
74
+ /// handle local inference, BEFORE any model download/load. The SDK
75
+ /// itself never shows UI for hardware decisions — consumer apps
76
+ /// call this and decide their own UX based on the returned `mode`:
77
+ ///
78
+ /// - `.ok` → device can comfortably run the model
79
+ /// locally; `start()` proceeds normally.
80
+ /// - `.offloadOnly` → device can run but slowly (below
81
+ /// `OffloadConfig.minLocalCapability`);
82
+ /// `start()` skips the model load and routes
83
+ /// every request to a paired peer.
84
+ /// - `.tooWeak` → device is below the hardware floor (3
85
+ /// tok/s by default); `start()` ALSO skips
86
+ /// the model load. Consumers typically bail
87
+ /// rather than even calling `start()`.
88
+ ///
89
+ /// The result is `Codable` so it round-trips cleanly through
90
+ /// Capacitor / React Native / Pigeon bridges as JSON.
91
+ public nonisolated func assessHardware(
92
+ hardwareMinimum: Double = 3.0,
93
+ minLocalCapability: Double = 10.0
94
+ ) -> HardwareAssessment {
95
+ let result = CapabilityPrecheck.assess(
96
+ thresholds: CapabilityPrecheck.Thresholds(
97
+ hardwareMinimum: hardwareMinimum,
98
+ minLocalCapability: minLocalCapability
99
+ )
100
+ )
101
+ return HardwareAssessment(from: result)
102
+ }
103
+
104
+ // MARK: - Lifecycle
105
+
106
+ /// v3.0+ surface: start with `StartOptions` (carries optional
107
+ /// `OffloadConfig`).
108
+ ///
109
+ /// v3.2 lifecycle:
110
+ /// - if `offload.enabled == true`, run the pre-init capability
111
+ /// gate (`assessHardware`) before any backend init.
112
+ /// - if the precheck returns `tooWeak` / `offloadOnly`, skip
113
+ /// backend init entirely (`offloadOnlyMode = true`); only the
114
+ /// OffloadRuntime + OffloadProxy come up. Every chat request
115
+ /// forwards to a paired peer.
116
+ /// - otherwise the inner `start(_ config:)` runs normally with
117
+ /// the backend on `httpBasePort + 100` (internal). The
118
+ /// OffloadProxy binds the user-facing `httpBasePort` and
119
+ /// decides per-request whether to forward locally or to a
120
+ /// peer.
121
+ ///
122
+ /// For v2.x backwards compat (no offload): the inner
123
+ /// `start(_ config:)` overload behaves exactly as before.
124
+ public func start(_ options: StartOptions) async throws -> BoundServer {
125
+ offloadOnlyMode = false
126
+
127
+ // v3.2.2 — License gate. Run BEFORE any backend init / port
128
+ // binding / model load: there's no point spending the resources
129
+ // if the SDK is going to refuse to operate.
130
+ //
131
+ // - DEBUG / simulator / DVAI_FORCE_DEV=1 → free-dev, proceed.
132
+ // - commercial / trial → proceed, attach to BoundServer.
133
+ // - free-prod / free-expired (production w/o license) → throws
134
+ // `LicenseRequiredError` (BSL 1.1 enforcement point).
135
+ let validator = LicenseValidator(options: LicenseValidatorOptions(
136
+ token: options.licenseToken,
137
+ path: options.licenseKeyPath
138
+ ))
139
+ let licenseStatus = try await validator.validateAndAssert()
140
+
141
+ let isOffloadEnabled = options.offload?.enabled == true
142
+ if isOffloadEnabled {
143
+ let assessment = assessHardware(
144
+ hardwareMinimum: 3.0,
145
+ minLocalCapability: options.offload!.minLocalCapability
146
+ )
147
+ offloadOnlyMode = (assessment.mode == .tooWeak || assessment.mode == .offloadOnly)
148
+ progressBroadcaster.emit(ProgressEvent(
149
+ phase: .load,
150
+ message: "[DVAI/precheck] \(assessment.mode.rawValue): \(assessment.reason)"
151
+ ))
152
+ }
153
+
154
+ // Determine the backend's internal port. When the proxy is in
155
+ // front, shift the backend off the user-facing port to avoid
156
+ // collision: backend at httpBasePort + 100, proxy at httpBasePort.
157
+ let userPort = options.config.httpBasePort
158
+ let backendOpts: DVAIBridgeConfig
159
+ if isOffloadEnabled && !offloadOnlyMode {
160
+ backendOpts = options.config.with(httpBasePort: userPort + 100)
161
+ } else {
162
+ backendOpts = options.config
163
+ }
164
+
165
+ let backendServer: BoundServer? = offloadOnlyMode
166
+ ? nil
167
+ : try await start(backendOpts)
168
+
169
+ // Bring up offload runtime + proxy when offload is enabled.
170
+ if isOffloadEnabled, let offload = options.offload {
171
+ if #available(iOS 14.0, macOS 11.0, *) {
172
+ let runtime = try OffloadRuntime(config: offload)
173
+ // OffloadRuntime.start expects a BoundServer for the
174
+ // advertiser's `port`. Use a synthetic one in offload-only
175
+ // mode (port = userPort, the proxy's port).
176
+ let resolvedBackend = try BackendSelector.resolve(options.config.backend, config: options.config)
177
+ let serverForRuntime = backendServer ?? BoundServer(
178
+ baseUrl: "http://127.0.0.1:\(userPort)",
179
+ port: userPort,
180
+ backend: resolvedBackend,
181
+ modelId: ""
182
+ )
183
+ try await runtime.start(
184
+ boundServer: serverForRuntime,
185
+ libraryVersion: DVAIBridgeVersion.current
186
+ )
187
+ self.offloadRuntime = runtime
188
+
189
+ // Spin up the OffloadProxy in front of the backend.
190
+ let deviceId = (try? runtime.deviceIDStore.get()) ?? "unknown"
191
+ let proxy = OffloadProxy(
192
+ backendBaseUrl: backendServer?.baseUrl,
193
+ offloadConfig: offload,
194
+ pairingPolicy: runtime.pairingPolicy,
195
+ peerProvider: { [weak runtime] in
196
+ guard let runtime else { return [] }
197
+ return await runtime.discovery.peers()
198
+ },
199
+ appId: "co.deepvoiceai.dvai-bridge",
200
+ selfDeviceId: deviceId
201
+ )
202
+ let boundProxyPort = try await proxy.start(basePort: userPort, maxAttempts: 16)
203
+ self.offloadProxy = proxy
204
+
205
+ let proxyServer = BoundServer(
206
+ baseUrl: "http://127.0.0.1:\(boundProxyPort)",
207
+ port: boundProxyPort,
208
+ backend: resolvedBackend,
209
+ modelId: backendServer?.modelId ?? "",
210
+ licenseStatus: licenseStatus
211
+ )
212
+ self.activeBaseUrl = proxyServer.baseUrl
213
+ if active == nil {
214
+ activeKind = proxyServer.backend
215
+ }
216
+ return proxyServer
217
+ }
218
+ }
219
+
220
+ return backendServer!.with(licenseStatus: licenseStatus)
221
+ }
222
+
223
+ /// Legacy v2.x entry point — takes a bare `DVAIBridgeConfig` with no
224
+ /// offload / license knobs. Internally called by the
225
+ /// `start(_ options:)` overload AFTER the license gate has already
226
+ /// run, so this method does NOT re-run validation. Callers that
227
+ /// hand-call `start(_ config:)` directly (no `StartOptions`) are
228
+ /// responsible for invoking `LicenseValidator().validateAndAssert()`
229
+ /// themselves if they need BSL 1.1 enforcement.
230
+ public func start(_ config: DVAIBridgeConfig) async throws -> BoundServer {
231
+ if let activeBaseUrl, let activeKind {
232
+ throw DVAIBridgeError.alreadyStarted(currentBackend: activeKind, baseUrl: activeBaseUrl)
233
+ }
234
+
235
+ let resolved = try BackendSelector.resolve(config.backend, config: config)
236
+ let opts = config.toCoreOpts()
237
+
238
+ let result: [String: Any]
239
+ let backend: BackendInstance
240
+
241
+ progressBroadcaster.emit(ProgressEvent(phase: .load))
242
+
243
+ switch resolved {
244
+ case .auto:
245
+ // BackendSelector.resolve never returns .auto; keep the compiler happy
246
+ throw DVAIBridgeError.configurationInvalid(reason: "BackendSelector returned .auto unexpectedly")
247
+ case .llama:
248
+ let state = PluginState()
249
+ do {
250
+ result = try await state.start(opts: opts)
251
+ } catch {
252
+ progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
253
+ throw DVAIBridgeError.modelLoadFailed(reason: error.localizedDescription)
254
+ }
255
+ backend = .llama(state)
256
+ case .foundation:
257
+ #if !COCOAPODS
258
+ let state = FoundationPluginState()
259
+ do {
260
+ result = try await state.start(opts: opts)
261
+ } catch {
262
+ progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
263
+ throw DVAIBridgeError.backendError(underlying: error.localizedDescription)
264
+ }
265
+ backend = .foundation(state)
266
+ #else
267
+ throw DVAIBridgeError.backendUnavailable(
268
+ .foundation,
269
+ reason: "Foundation Models backend is not available in CocoaPods builds of dvai-bridge — Apple's FoundationModels framework triggers private-framework autolink directives that CocoaPods consumers cannot link. Use SwiftPM if your app needs the Foundation backend, or use .llama or .coreml instead."
270
+ )
271
+ #endif
272
+ case .coreml:
273
+ // iOS 18.1 floor of this package already satisfies CoreMLPluginState's
274
+ // iOS 18.0 requirement, but macOS 14 (the package floor) does not
275
+ // satisfy its macOS 15.0 requirement — gate explicitly.
276
+ if #available(macOS 15.0, *) {
277
+ let state = CoreMLPluginState()
278
+ do {
279
+ result = try await state.start(opts: opts)
280
+ } catch {
281
+ progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
282
+ throw DVAIBridgeError.backendUnavailable(.coreml, reason: error.localizedDescription)
283
+ }
284
+ backend = .coreml(state)
285
+ } else {
286
+ throw DVAIBridgeError.backendUnavailable(.coreml, reason: "Requires macOS 15+")
287
+ }
288
+ case .mlx:
289
+ #if !COCOAPODS
290
+ let state = MLXPluginState()
291
+ do {
292
+ result = try await state.start(opts: opts)
293
+ } catch {
294
+ progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
295
+ throw DVAIBridgeError.backendUnavailable(.mlx, reason: error.localizedDescription)
296
+ }
297
+ backend = .mlx(state)
298
+ #else
299
+ throw DVAIBridgeError.backendUnavailable(
300
+ .mlx,
301
+ reason: "MLX backend is not available in CocoaPods builds of dvai-bridge — mlx-swift-lm's transitive dependencies don't publish CocoaPods specs. Use SwiftPM if your app needs the MLX backend, or use .llama or .coreml instead."
302
+ )
303
+ #endif
304
+ }
305
+
306
+ let server = try BoundServer(coreResult: result, backend: resolved)
307
+ self.active = backend
308
+ self.activeKind = resolved
309
+ self.activeBaseUrl = server.baseUrl
310
+
311
+ progressBroadcaster.emit(ProgressEvent(phase: .ready))
312
+
313
+ let serverCopy = server
314
+ await MainActor.run {
315
+ DVAIBridgeReactiveStateRegistry.shared.state(for: self).didStart(serverCopy)
316
+ }
317
+ return server
318
+ }
319
+
320
+ public func stop() async throws {
321
+ // v3.2 — tear down the proxy first so consumer requests stop
322
+ // arriving before we drop the backend; then stop the offload
323
+ // runtime (discovery + advertiser) before the backend dies.
324
+ if #available(iOS 14.0, macOS 11.0, *) {
325
+ if let proxy = offloadProxy as? OffloadProxy {
326
+ await proxy.stop()
327
+ }
328
+ }
329
+ offloadProxy = nil
330
+ offloadOnlyMode = false
331
+
332
+ // Tear down the offload runtime — it depends on the bound
333
+ // server being up while we stop discovery cleanly.
334
+ if #available(iOS 14.0, macOS 11.0, *) {
335
+ if let runtime = offloadRuntime as? OffloadRuntime {
336
+ await runtime.stop()
337
+ }
338
+ }
339
+ offloadRuntime = nil
340
+
341
+ guard let backend = active else {
342
+ return // idempotent
343
+ }
344
+ do {
345
+ switch backend {
346
+ case .llama(let state):
347
+ try await state.stop()
348
+ #if !COCOAPODS
349
+ case .foundation(let state):
350
+ try await state.stop()
351
+ #endif
352
+ case .coreml(let any):
353
+ // Always gated — macOS 14 can never have stored a coreml state
354
+ // here (start() rejects it), so this branch is unreachable on
355
+ // pre-15 macOS, but the availability check is required by the
356
+ // type system.
357
+ if #available(macOS 15.0, *) {
358
+ if let state = any as? CoreMLPluginState {
359
+ try await state.stop()
360
+ }
361
+ }
362
+ #if !COCOAPODS
363
+ case .mlx(let state):
364
+ try await state.stop()
365
+ #endif
366
+ }
367
+ } catch {
368
+ // Even if stop() throws, clear state — caller can't usefully retry
369
+ self.active = nil
370
+ self.activeKind = nil
371
+ self.activeBaseUrl = nil
372
+ await MainActor.run {
373
+ DVAIBridgeReactiveStateRegistry.shared.state(for: self).didStop()
374
+ }
375
+ throw DVAIBridgeError.backendError(underlying: error.localizedDescription)
376
+ }
377
+ self.active = nil
378
+ self.activeKind = nil
379
+ self.activeBaseUrl = nil
380
+ await MainActor.run {
381
+ DVAIBridgeReactiveStateRegistry.shared.state(for: self).didStop()
382
+ }
383
+ }
384
+
385
+ // MARK: - Status
386
+
387
+ public struct StatusInfo: Sendable, Equatable {
388
+ public let running: Bool
389
+ public let backend: BackendKind?
390
+ public let baseUrl: String?
391
+ }
392
+
393
+ public func status() -> StatusInfo {
394
+ StatusInfo(
395
+ running: active != nil,
396
+ backend: activeKind,
397
+ baseUrl: activeBaseUrl
398
+ )
399
+ }
400
+
401
+ // MARK: - Progress observation
402
+
403
+ public nonisolated var progressPublisher: AnyPublisher<ProgressEvent, Never> {
404
+ progressBroadcaster.publisher
405
+ }
406
+
407
+ public nonisolated var progressStream: AsyncStream<ProgressEvent> {
408
+ progressBroadcaster.makeStream()
409
+ }
410
+
411
+ @discardableResult
412
+ public nonisolated func addProgressListener(
413
+ _ cb: @escaping @Sendable (ProgressEvent) -> Void
414
+ ) -> CancellationToken {
415
+ progressBroadcaster.addCallback(cb)
416
+ }
417
+
418
+ // MARK: - Model management (delegates to ModelDownloader)
419
+
420
+ public struct DownloadOptions: Sendable {
421
+ public var url: URL
422
+ public var sha256: String
423
+ public var destFilename: String?
424
+ public var headers: [String: String]
425
+ public init(url: URL, sha256: String, destFilename: String? = nil, headers: [String: String] = [:]) {
426
+ self.url = url
427
+ self.sha256 = sha256
428
+ self.destFilename = destFilename
429
+ self.headers = headers
430
+ }
431
+ }
432
+
433
+ public struct DownloadResult: Sendable, Equatable {
434
+ public let path: String
435
+ public let cached: Bool
436
+ public init(path: String, cached: Bool) {
437
+ self.path = path
438
+ self.cached = cached
439
+ }
440
+ }
441
+
442
+ public func downloadModel(_ opts: DownloadOptions) async throws -> DownloadResult {
443
+ let dest = opts.destFilename ?? opts.url.lastPathComponent
444
+ progressBroadcaster.emit(ProgressEvent(phase: .download))
445
+ do {
446
+ let coreResult = try await downloader.downloadModel(
447
+ url: opts.url,
448
+ expectedSha256: opts.sha256,
449
+ destFilename: dest,
450
+ headers: opts.headers,
451
+ onProgress: { [weak self] (received: Int64, total: Int64?) in
452
+ let percent: Double? = total.flatMap { $0 > 0 ? (Double(received) / Double($0)) * 100.0 : nil }
453
+ self?.progressBroadcaster.emit(ProgressEvent(
454
+ phase: .download,
455
+ bytesReceived: received,
456
+ bytesTotal: total,
457
+ percent: percent
458
+ ))
459
+ }
460
+ )
461
+ progressBroadcaster.emit(ProgressEvent(phase: .verify))
462
+ return DownloadResult(path: coreResult.path, cached: coreResult.cached)
463
+ } catch {
464
+ progressBroadcaster.emit(ProgressEvent(phase: .error, message: error.localizedDescription))
465
+ if case ModelDownloader.DownloadError.checksumMismatch = error {
466
+ throw DVAIBridgeError.checksumMismatch
467
+ }
468
+ throw DVAIBridgeError.downloadFailed(reason: error.localizedDescription)
469
+ }
470
+ }
471
+
472
+ public func listCachedModels() async throws -> [CachedModelInfoSwift] {
473
+ try await downloader.listCachedModels()
474
+ }
475
+
476
+ public func deleteCachedModel(filename: String) async throws {
477
+ try await downloader.deleteCachedModel(filename: filename)
478
+ }
479
+
480
+ public func cacheDir() async throws -> String {
481
+ try await downloader.cacheDirPath()
482
+ }
483
+
484
+ // MARK: - Offload (v3.0)
485
+
486
+ /// AsyncStream of incoming pairing requests. The host app awaits
487
+ /// `for await req in await DVAIBridge.shared.pairingRequests()` and
488
+ /// calls `req.respond(approved:)` to approve or deny each one.
489
+ ///
490
+ /// Returns an empty (immediately-finished) stream when offload isn't
491
+ /// enabled or hasn't been started yet.
492
+ public func pairingRequests() -> AsyncStream<PairingRequest> {
493
+ if #available(iOS 14.0, macOS 11.0, *) {
494
+ if let runtime = self.offloadRuntime as? OffloadRuntime {
495
+ return runtime.pairingRequestStream()
496
+ }
497
+ }
498
+ return Self.emptyStream()
499
+ }
500
+
501
+ /// AsyncStream of LAN-discovery events (peer-up / peer-down).
502
+ /// Empty if offload isn't enabled.
503
+ @available(iOS 14.0, macOS 11.0, *)
504
+ public func discoveryEvents() -> AsyncStream<NWBrowserDiscovery.Event> {
505
+ if let runtime = self.offloadRuntime as? OffloadRuntime {
506
+ return runtime.discoveryEventStream()
507
+ }
508
+ return AsyncStream { continuation in
509
+ continuation.finish()
510
+ }
511
+ }
512
+
513
+ /// Stable per-install device ID for THIS device. Generated on
514
+ /// first call and persisted under
515
+ /// `<Application Support>/dvai-bridge/device-id`. Used by host
516
+ /// apps to filter the iPhone's own `_dvai-bridge._tcp`
517
+ /// advertisement out of `discoveryEvents()` (NWBrowser surfaces
518
+ /// the local device's own service alongside remote peers — match
519
+ /// against this id to drop the self-loop).
520
+ ///
521
+ /// - Throws if offload isn't enabled / start() hasn't run.
522
+ @available(iOS 14.0, macOS 11.0, *)
523
+ public func deviceId() async throws -> String {
524
+ guard let runtime = self.offloadRuntime as? OffloadRuntime else {
525
+ throw DVAIBridgeError.configurationInvalid(reason:
526
+ "deviceId requires offload to be enabled and start() to have been called.")
527
+ }
528
+ return try runtime.deviceIDStore.get()
529
+ }
530
+
531
+ /// v3.2.1 — initiate a LAN pairing handshake against a discovered
532
+ /// peer. POSTs `/v1/dvai/handshake` to `peer.baseUrl` with our
533
+ /// device identity in the body; on the peer's approval, persists
534
+ /// the returned pairing key to our local `PairingStore` so future
535
+ /// offload requests to that peer get HMAC-signed.
536
+ ///
537
+ /// Wire-compatible with the TS-side `handleHandshake` in
538
+ /// `packages/dvai-bridge-core/src/handlers/dvai/index.ts` AND the
539
+ /// matching iOS-side handler that ships with this release in
540
+ /// `OffloadProxy.handleHandshakeRequest`.
541
+ ///
542
+ /// - Throws `DVAIBridgeError.configurationInvalid` if offload
543
+ /// isn't started, or the peer rejects the handshake (HTTP 4xx),
544
+ /// or the response body is malformed.
545
+ /// - Throws underlying URLSession errors on transport failure.
546
+ @available(iOS 14.0, macOS 11.0, *)
547
+ public func initiatePairing(with peer: MDNSPeer) async throws -> Pairing {
548
+ guard let runtime = self.offloadRuntime as? OffloadRuntime else {
549
+ throw DVAIBridgeError.configurationInvalid(reason:
550
+ "initiatePairing requires offload to be enabled and start() to have been called.")
551
+ }
552
+
553
+ // Identity of THIS device — what we send to the peer so it
554
+ // knows who's asking. The peer's UI surfaces these strings
555
+ // in its approval prompt.
556
+ let selfDeviceId = try runtime.deviceIDStore.get()
557
+ let selfDeviceName = await Self.resolveSelfName()
558
+
559
+ // peer.baseUrl already ends in `/v1` (NWBrowserDiscovery
560
+ // synthesises it as `<scheme>://<host>:<port>/v1`). Strip
561
+ // that trailing segment before appending `/v1/dvai/handshake`,
562
+ // otherwise the URL becomes `…/v1/v1/dvai/handshake` and the
563
+ // peer 404s.
564
+ let trimmedBase = peer.baseUrl.hasSuffix("/v1")
565
+ ? String(peer.baseUrl.dropLast("/v1".count))
566
+ : peer.baseUrl
567
+ guard let url = URL(string: trimmedBase + "/v1/dvai/handshake") else {
568
+ throw DVAIBridgeError.configurationInvalid(reason:
569
+ "[DVAI/pairing] could not construct handshake URL from baseUrl=\(peer.baseUrl)")
570
+ }
571
+ var req = URLRequest(url: url)
572
+ req.httpMethod = "POST"
573
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
574
+ let bodyDict: [String: Any] = [
575
+ "peerDeviceId": selfDeviceId,
576
+ "peerDeviceName": selfDeviceName,
577
+ "via": "lan-handshake",
578
+ "appId": "co.deepvoiceai.dvai-bridge",
579
+ ]
580
+ req.httpBody = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
581
+ req.timeoutInterval = 60.0 // matches the iOS pairing-policy default
582
+
583
+ let session = URLSession(configuration: .ephemeral)
584
+ let (data, response) = try await session.data(for: req)
585
+ guard let http = response as? HTTPURLResponse else {
586
+ throw DVAIBridgeError.configurationInvalid(reason: "[DVAI/pairing] non-HTTP response from peer")
587
+ }
588
+ if http.statusCode != 200 {
589
+ let detail = String(data: data, encoding: .utf8) ?? "<no body>"
590
+ throw DVAIBridgeError.configurationInvalid(reason:
591
+ "[DVAI/pairing] peer rejected handshake (HTTP \(http.statusCode)): \(detail)")
592
+ }
593
+
594
+ // Response shape mirrors the TS handler:
595
+ // { paired: true, pairedAt, via, pairingKey, peerDeviceId }
596
+ guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
597
+ json["paired"] as? Bool == true,
598
+ let pairingKey = json["pairingKey"] as? String,
599
+ let peerDeviceIdResp = json["peerDeviceId"] as? String,
600
+ let pairedAt = (json["pairedAt"] as? Int64)
601
+ ?? (json["pairedAt"] as? Int).map(Int64.init) else {
602
+ throw DVAIBridgeError.configurationInvalid(reason:
603
+ "[DVAI/pairing] malformed handshake response from peer")
604
+ }
605
+ let viaRaw = (json["via"] as? String) ?? "lan-handshake"
606
+ let via = Pairing.Via(rawValue: viaRaw) ?? .lanHandshake
607
+
608
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
609
+ let pairing = Pairing(
610
+ peerDeviceId: peerDeviceIdResp,
611
+ peerDeviceName: peer.deviceName,
612
+ pairingKey: pairingKey,
613
+ pairedAt: pairedAt,
614
+ lastUsedAt: nowMs,
615
+ via: via,
616
+ // v3.2.1 — capture the peer's baseUrl so the OffloadProxy
617
+ // can route here even when the peer isn't currently in
618
+ // mDNS discovery (the macOS Hub case). We store the
619
+ // ORIGINAL baseUrl supplied to this method, not the
620
+ // possibly-mutated handshake URL — `peer.baseUrl` is the
621
+ // OpenAI-API root (`<scheme>://<host>:<port>/v1`) the
622
+ // OffloadProxy can forward chat requests to.
623
+ baseUrl: peer.baseUrl
624
+ )
625
+ try await runtime.pairingStore.set(pairing)
626
+ return pairing
627
+ }
628
+
629
+ /// Best-effort device-name lookup used by `initiatePairing`. iOS
630
+ /// blocks `UIDevice.current.name` off the main thread; fall back
631
+ /// to the host name when off-main.
632
+ private static func resolveSelfName() async -> String {
633
+ #if canImport(UIKit) && !os(macOS)
634
+ return await MainActor.run { UIDevice.current.name }
635
+ #else
636
+ return ProcessInfo.processInfo.hostName
637
+ #endif
638
+ }
639
+
640
+ private static func emptyStream<T: Sendable>() -> AsyncStream<T> {
641
+ AsyncStream<T> { continuation in
642
+ continuation.finish()
643
+ }
644
+ }
645
+
646
+ /// Test-only accessor for the current offload runtime, if any.
647
+ /// Returns nil when offload isn't enabled or `start()` hasn't run.
648
+ @available(iOS 14.0, macOS 11.0, *)
649
+ internal func _testOffloadRuntime() -> OffloadRuntime? {
650
+ offloadRuntime as? OffloadRuntime
651
+ }
652
+ }
653
+
654
+ /// Library SemVer constant — keep in sync with the package's published
655
+ /// version. Used by the mDNS advertiser TXT record.
656
+ public enum DVAIBridgeVersion {
657
+ public static let current = "4.0.0"
658
+ }