@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.
- package/Package.swift +104 -104
- package/ios/Sources/DVAIBridge/BackendKind.swift +23 -23
- package/ios/Sources/DVAIBridge/BoundServer.swift +46 -46
- package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -658
- package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -86
- package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -33
- package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -59
- package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -84
- package/ios/Sources/DVAIBridge/License/Audience.swift +133 -133
- package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -164
- package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -392
- package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -114
- package/ios/Sources/DVAIBridge/License/Types.swift +195 -195
- package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -118
- package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -34
- package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -19
- package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -123
- package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -130
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -137
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -108
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -96
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -69
- package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -53
- package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -18
- package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -11
- package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -32
- package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -41
- package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -40
- package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -19
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -37
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -52
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -33
- package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -658
- package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -69
- package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -25
- package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -45
- package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +385 -359
- package/package.json +3 -4
- package/DVAIBridge.podspec +0 -120
- package/LICENSE +0 -51
- 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
|
+
}
|