@dvai-bridge/ios 4.0.0

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 (60) hide show
  1. package/DVAIBridge.podspec +120 -0
  2. package/LICENSE +51 -0
  3. package/Package.swift +104 -0
  4. package/README.md +199 -0
  5. package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
  6. package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
  7. package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
  8. package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
  9. package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
  10. package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
  11. package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
  12. package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
  13. package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
  14. package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
  15. package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
  16. package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
  17. package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
  18. package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
  19. package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
  20. package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
  21. package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
  22. package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
  23. package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
  24. package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
  25. package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
  26. package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
  27. package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
  28. package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
  29. package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
  30. package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
  31. package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
  32. package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
  33. package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
  34. package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
  35. package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
  36. package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
  37. package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
  38. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
  39. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
  40. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
  41. package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
  42. package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
  43. package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
  44. package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
  45. package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
  46. package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
  47. package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
  48. package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
  49. package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
  50. package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
  51. package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
  52. package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
  53. package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
  54. package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
  55. package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
  56. package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
  57. package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
  58. package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
  59. package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
  60. package/package.json +19 -0
@@ -0,0 +1,86 @@
1
+ import Foundation
2
+
3
+ public struct DVAIBridgeConfig: Sendable {
4
+ public enum CORSOrigin: Sendable {
5
+ case wildcard
6
+ case exact(String)
7
+ case allowlist([String])
8
+ }
9
+
10
+ public var backend: BackendKind
11
+ public var modelPath: String?
12
+ public var mmprojPath: String?
13
+ public var tokenizerPath: String?
14
+ public var gpuLayers: Int
15
+ public var contextSize: Int
16
+ public var threads: Int
17
+ public var embeddingMode: Bool
18
+ public var httpBasePort: Int
19
+ public var httpMaxPortAttempts: Int
20
+ public var corsOrigin: CORSOrigin
21
+ public var autoUnloadOnLowMemory: Bool
22
+ public var logLevel: String // "silent" | "info" | "debug" — matches the Capacitor surface
23
+
24
+ public init(
25
+ backend: BackendKind = .auto,
26
+ modelPath: String? = nil,
27
+ mmprojPath: String? = nil,
28
+ tokenizerPath: String? = nil,
29
+ gpuLayers: Int = 99,
30
+ contextSize: Int = 2048,
31
+ threads: Int = 4,
32
+ embeddingMode: Bool = false,
33
+ httpBasePort: Int = 38883,
34
+ httpMaxPortAttempts: Int = 16,
35
+ corsOrigin: CORSOrigin = .wildcard,
36
+ autoUnloadOnLowMemory: Bool = false,
37
+ logLevel: String = "info"
38
+ ) {
39
+ self.backend = backend
40
+ self.modelPath = modelPath
41
+ self.mmprojPath = mmprojPath
42
+ self.tokenizerPath = tokenizerPath
43
+ self.gpuLayers = gpuLayers
44
+ self.contextSize = contextSize
45
+ self.threads = threads
46
+ self.embeddingMode = embeddingMode
47
+ self.httpBasePort = httpBasePort
48
+ self.httpMaxPortAttempts = httpMaxPortAttempts
49
+ self.corsOrigin = corsOrigin
50
+ self.autoUnloadOnLowMemory = autoUnloadOnLowMemory
51
+ self.logLevel = logLevel
52
+ }
53
+
54
+ /// v3.2 — copy with `httpBasePort` overridden. Used by the
55
+ /// OffloadProxy lifecycle to push the backend off the user-facing
56
+ /// port (proxy claims `httpBasePort`, backend gets `httpBasePort + 100`).
57
+ public func with(httpBasePort newPort: Int) -> DVAIBridgeConfig {
58
+ var copy = self
59
+ copy.httpBasePort = newPort
60
+ return copy
61
+ }
62
+
63
+ /// Translate this config into the `[String: Any]` shape the underlying
64
+ /// core PluginStates expect (matches the Capacitor JSObject shape).
65
+ internal func toCoreOpts() -> [String: Any] {
66
+ var opts: [String: Any] = [
67
+ "gpuLayers": gpuLayers,
68
+ "contextSize": contextSize,
69
+ "threads": threads,
70
+ "embeddingMode": embeddingMode,
71
+ "httpBasePort": httpBasePort,
72
+ "httpMaxPortAttempts": httpMaxPortAttempts,
73
+ "autoUnloadOnLowMemory": autoUnloadOnLowMemory,
74
+ "logLevel": logLevel,
75
+ ]
76
+ if let modelPath { opts["modelPath"] = modelPath }
77
+ if let mmprojPath { opts["mmprojPath"] = mmprojPath }
78
+ if let tokenizerPath { opts["tokenizerPath"] = tokenizerPath }
79
+ switch corsOrigin {
80
+ case .wildcard: opts["corsOrigin"] = "*"
81
+ case .exact(let s): opts["corsOrigin"] = s
82
+ case .allowlist(let xs): opts["corsOrigin"] = xs
83
+ }
84
+ return opts
85
+ }
86
+ }
@@ -0,0 +1,33 @@
1
+ import Foundation
2
+
3
+ public enum DVAIBridgeError: Error, LocalizedError, Sendable {
4
+ case notStarted
5
+ case alreadyStarted(currentBackend: BackendKind, baseUrl: String)
6
+ case configurationInvalid(reason: String)
7
+ case backendUnavailable(BackendKind, reason: String)
8
+ case modelLoadFailed(reason: String)
9
+ case downloadFailed(reason: String)
10
+ case checksumMismatch
11
+ case backendError(underlying: String)
12
+
13
+ public var errorDescription: String? {
14
+ switch self {
15
+ case .notStarted:
16
+ return "DVAIBridge has not been started. Call DVAIBridge.shared.start(...) first."
17
+ case .alreadyStarted(let backend, let baseUrl):
18
+ return "DVAIBridge is already running with backend \(backend) at \(baseUrl). Call stop() before starting a new session."
19
+ case .configurationInvalid(let reason):
20
+ return "Configuration invalid: \(reason)"
21
+ case .backendUnavailable(let backend, let reason):
22
+ return "Backend \(backend) is unavailable on this device: \(reason)"
23
+ case .modelLoadFailed(let reason):
24
+ return "Model load failed: \(reason)"
25
+ case .downloadFailed(let reason):
26
+ return "Download failed: \(reason)"
27
+ case .checksumMismatch:
28
+ return "Downloaded file's SHA-256 didn't match the expected value. The file has been deleted from the cache."
29
+ case .backendError(let msg):
30
+ return "Backend error: \(msg)"
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,64 @@
1
+ import Foundation
2
+
3
+ /// Swift-side `Peer` value mirroring the TypeScript `Peer` shape in
4
+ /// `packages/dvai-bridge-core/src/discovery/types.ts`. Used by the
5
+ /// iOS-native discovery layer (`NWBrowserDiscovery`) and surfaced to
6
+ /// host apps via the offload module.
7
+ public struct MDNSPeer: Sendable, Equatable, Codable, Hashable {
8
+ /// Stable per-install device ID of the peer.
9
+ public let deviceId: String
10
+ /// Human-readable hint (iOS device name, hostname, etc.).
11
+ public let deviceName: String
12
+ /// Library SemVer the peer is running.
13
+ public let dvaiVersion: String
14
+ /// OpenAI-compatible base URL the peer's local server exposes.
15
+ public let baseUrl: String
16
+ /// Models the peer claims to have loaded right now.
17
+ public let loadedModels: [String]
18
+ /// Peer-reported capability map: { modelId → tok/s }.
19
+ public let capability: [String: Double]
20
+ /// Discovery source — useful for diagnostics.
21
+ public let via: Via
22
+ /// Whether the peer's URL uses TLS.
23
+ public let secure: Bool
24
+ /// Last-seen unix ms — discovery sources update this.
25
+ public let lastSeenAt: Int64
26
+
27
+ public enum Via: String, Sendable, Codable, Hashable {
28
+ case mdns
29
+ case `static`
30
+ case rendezvous
31
+ case custom
32
+ }
33
+
34
+ public init(
35
+ deviceId: String,
36
+ deviceName: String,
37
+ dvaiVersion: String,
38
+ baseUrl: String,
39
+ loadedModels: [String] = [],
40
+ capability: [String: Double] = [:],
41
+ via: Via = .mdns,
42
+ secure: Bool = false,
43
+ lastSeenAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
44
+ ) {
45
+ self.deviceId = deviceId
46
+ self.deviceName = deviceName
47
+ self.dvaiVersion = dvaiVersion
48
+ self.baseUrl = baseUrl
49
+ self.loadedModels = loadedModels
50
+ self.capability = capability
51
+ self.via = via
52
+ self.secure = secure
53
+ self.lastSeenAt = lastSeenAt
54
+ }
55
+ }
56
+
57
+ /// Service-type advertised on mDNS for dvai-bridge instances. Mirrors the
58
+ /// `MDNS_SERVICE_TYPE` constant in `discovery/types.ts`.
59
+ public enum DVAIBridgeMDNS {
60
+ public static let serviceType = "_dvai-bridge._tcp"
61
+ public static let serviceDomain = "local."
62
+ /// Full DNS-SD service-type form used by some APIs.
63
+ public static let serviceTypeFull = "_dvai-bridge._tcp.local"
64
+ }
@@ -0,0 +1,103 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// Advertises THIS device on Bonjour as a `_dvai-bridge._tcp` service
5
+ /// using `NWListener`. The TXT record carries the fields documented in
6
+ /// the design spec section 4.2.
7
+ @available(iOS 14.0, macOS 11.0, *)
8
+ public actor NWAdvertiser {
9
+ public struct Advertisement: Sendable, Equatable {
10
+ public let deviceId: String
11
+ public let deviceName: String
12
+ public let dvaiVersion: String
13
+ public let port: Int
14
+ public let secure: Bool
15
+ public let loadedModels: [String]
16
+ public let capability: [String: Double]
17
+
18
+ public init(
19
+ deviceId: String,
20
+ deviceName: String,
21
+ dvaiVersion: String,
22
+ port: Int,
23
+ secure: Bool = false,
24
+ loadedModels: [String] = [],
25
+ capability: [String: Double] = [:]
26
+ ) {
27
+ self.deviceId = deviceId
28
+ self.deviceName = deviceName
29
+ self.dvaiVersion = dvaiVersion
30
+ self.port = port
31
+ self.secure = secure
32
+ self.loadedModels = loadedModels
33
+ self.capability = capability
34
+ }
35
+ }
36
+
37
+ private var listener: NWListener?
38
+ private var advertised: Advertisement?
39
+
40
+ public init() {}
41
+
42
+ /// Start advertising. Idempotent — if already advertising, the new
43
+ /// advertisement supersedes the old one.
44
+ public func start(_ ad: Advertisement) async throws {
45
+ await stop()
46
+
47
+ let params = NWParameters.tcp
48
+ params.allowLocalEndpointReuse = true
49
+ // Use a TCP listener bound to the same advertisement port. The
50
+ // listener itself doesn't have to actually accept connections —
51
+ // the upstream embedded HTTP server does that on the same port.
52
+ // We just need the Bonjour record present.
53
+ let port = NWEndpoint.Port(rawValue: UInt16(ad.port)) ?? .any
54
+ let listener = try NWListener(using: params, on: port)
55
+ // Important: we're advertising a service that ANOTHER socket
56
+ // (the Telegraph HTTP server) actually listens on. NWListener
57
+ // would normally accept these connections — null-route them
58
+ // by just cancelling immediately. The Bonjour TXT record is
59
+ // what we care about.
60
+ listener.newConnectionHandler = { conn in
61
+ conn.cancel()
62
+ }
63
+
64
+ let txt = Self.makeTxtRecord(ad)
65
+ listener.service = NWListener.Service(
66
+ name: ad.deviceName,
67
+ type: DVAIBridgeMDNS.serviceType,
68
+ domain: nil,
69
+ txtRecord: txt
70
+ )
71
+
72
+ listener.start(queue: .global(qos: .utility))
73
+ self.listener = listener
74
+ self.advertised = ad
75
+ }
76
+
77
+ public func stop() async {
78
+ listener?.cancel()
79
+ listener = nil
80
+ advertised = nil
81
+ }
82
+
83
+ public func currentAdvertisement() -> Advertisement? {
84
+ advertised
85
+ }
86
+
87
+ /// Build the Bonjour TXT record for an advertisement. Mirrors the
88
+ /// fields documented in the design spec section 4.2.
89
+ static func makeTxtRecord(_ ad: Advertisement) -> NWTXTRecord {
90
+ var rec = NWTXTRecord()
91
+ rec["deviceId"] = ad.deviceId
92
+ rec["deviceName"] = ad.deviceName
93
+ rec["dvaiVersion"] = ad.dvaiVersion
94
+ rec["port"] = String(ad.port)
95
+ rec["secure"] = ad.secure ? "1" : "0"
96
+ rec["models"] = ad.loadedModels.joined(separator: ",")
97
+ if let capData = try? JSONSerialization.data(withJSONObject: ad.capability),
98
+ let capStr = String(data: capData, encoding: .utf8) {
99
+ rec["capability"] = capStr
100
+ }
101
+ return rec
102
+ }
103
+ }
@@ -0,0 +1,212 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ /// LAN peer discovery via Apple's Network framework. Uses `NWBrowser`
5
+ /// with the Bonjour service-type `_dvai-bridge._tcp` and parses TXT
6
+ /// records into `MDNSPeer` values.
7
+ ///
8
+ /// Mirrors the contract of `IDiscovery` in
9
+ /// `packages/dvai-bridge-core/src/discovery/types.ts`.
10
+ @available(iOS 14.0, macOS 11.0, *)
11
+ public actor NWBrowserDiscovery {
12
+ public enum Event: Sendable {
13
+ case peerUp(MDNSPeer)
14
+ case peerDown(deviceId: String)
15
+ case error(String)
16
+ }
17
+
18
+ private let browser: NWBrowser
19
+ /// Map: endpointDescription → resolved peer. NWBrowser uses
20
+ /// `NWBrowser.Result` whose endpoint is the stable identity for
21
+ /// add/remove tracking.
22
+ private var resultsByEndpoint: [String: MDNSPeer] = [:]
23
+ private let continuation: AsyncStream<Event>.Continuation
24
+ public nonisolated let events: AsyncStream<Event>
25
+ private var started = false
26
+ /// Our own deviceId. NWBrowser surfaces the local device's own
27
+ /// `_dvai-bridge._tcp` advertisement alongside remote peers, so
28
+ /// we filter parses whose `deviceId` matches and refuse to emit
29
+ /// peerUp events for them. Set by `OffloadRuntime.start()`
30
+ /// before `start()` is called.
31
+ private var selfDeviceId: String?
32
+
33
+ public init() {
34
+ let descriptor = NWBrowser.Descriptor.bonjourWithTXTRecord(
35
+ type: DVAIBridgeMDNS.serviceType,
36
+ domain: nil
37
+ )
38
+ self.browser = NWBrowser(for: descriptor, using: NWParameters())
39
+ var savedContinuation: AsyncStream<Event>.Continuation!
40
+ self.events = AsyncStream<Event> { c in
41
+ savedContinuation = c
42
+ }
43
+ self.continuation = savedContinuation
44
+ }
45
+
46
+ /// Configure self-filtering. Called from `OffloadRuntime.start`
47
+ /// once the deviceId is resolved. Without this, the OffloadProxy
48
+ /// would see (and try to forward to) the iPhone's own service.
49
+ public func setSelfDeviceId(_ id: String) {
50
+ self.selfDeviceId = id
51
+ // Drop any already-known peers that match — racing peerUp
52
+ // events can land before the id is set.
53
+ for (key, peer) in resultsByEndpoint where peer.deviceId == id {
54
+ resultsByEndpoint.removeValue(forKey: key)
55
+ }
56
+ }
57
+
58
+ /// Start browsing. Idempotent.
59
+ public func start() {
60
+ guard !started else { return }
61
+ started = true
62
+
63
+ browser.browseResultsChangedHandler = { [weak self] results, changes in
64
+ guard let self else { return }
65
+ Task { await self.handleChanges(results: results, changes: changes) }
66
+ }
67
+ browser.stateUpdateHandler = { [weak self] state in
68
+ guard let self else { return }
69
+ Task { await self.handleState(state) }
70
+ }
71
+ browser.start(queue: .global(qos: .utility))
72
+ }
73
+
74
+ /// Stop browsing. Idempotent.
75
+ public func stop() {
76
+ guard started else { return }
77
+ started = false
78
+ browser.cancel()
79
+ continuation.finish()
80
+ }
81
+
82
+ /// Snapshot of currently-known peers.
83
+ public func peers() -> [MDNSPeer] {
84
+ Array(resultsByEndpoint.values)
85
+ }
86
+
87
+ private func handleState(_ state: NWBrowser.State) {
88
+ switch state {
89
+ case .failed(let err):
90
+ continuation.yield(.error("NWBrowser failed: \(err)"))
91
+ case .cancelled:
92
+ continuation.yield(.error("NWBrowser cancelled"))
93
+ default:
94
+ break
95
+ }
96
+ }
97
+
98
+ private func handleChanges(
99
+ results: Set<NWBrowser.Result>,
100
+ changes: Set<NWBrowser.Result.Change>
101
+ ) {
102
+ for change in changes {
103
+ switch change {
104
+ case .added(let result):
105
+ if let peer = parsePeer(from: result) {
106
+ if let myId = self.selfDeviceId, peer.deviceId == myId {
107
+ // Skip our own advertisement — NWBrowser
108
+ // doesn't filter self automatically.
109
+ continue
110
+ }
111
+ let key = endpointKey(result.endpoint)
112
+ resultsByEndpoint[key] = peer
113
+ continuation.yield(.peerUp(peer))
114
+ }
115
+ case .removed(let result):
116
+ let key = endpointKey(result.endpoint)
117
+ if let peer = resultsByEndpoint.removeValue(forKey: key) {
118
+ continuation.yield(.peerDown(deviceId: peer.deviceId))
119
+ }
120
+ case .changed(let old, let new, _):
121
+ let oldKey = endpointKey(old.endpoint)
122
+ let newKey = endpointKey(new.endpoint)
123
+ if let peer = parsePeer(from: new) {
124
+ if let myId = self.selfDeviceId, peer.deviceId == myId {
125
+ // Drop any cached entry — never emit self.
126
+ resultsByEndpoint.removeValue(forKey: oldKey)
127
+ resultsByEndpoint.removeValue(forKey: newKey)
128
+ continue
129
+ }
130
+ if oldKey != newKey {
131
+ resultsByEndpoint.removeValue(forKey: oldKey)
132
+ }
133
+ resultsByEndpoint[newKey] = peer
134
+ continuation.yield(.peerUp(peer))
135
+ }
136
+ case .identical:
137
+ break
138
+ @unknown default:
139
+ break
140
+ }
141
+ }
142
+ }
143
+
144
+ private func endpointKey(_ endpoint: NWEndpoint) -> String {
145
+ return "\(endpoint)"
146
+ }
147
+
148
+ /// Parse an `NWBrowser.Result` (which carries the TXT metadata)
149
+ /// into an `MDNSPeer`. Returns nil if the TXT record doesn't
150
+ /// carry the minimum-required fields.
151
+ private func parsePeer(from result: NWBrowser.Result) -> MDNSPeer? {
152
+ guard case .bonjour(let txt) = result.metadata else { return nil }
153
+ let dict = txt.dictionary
154
+ guard let deviceId = dict["deviceId"], !deviceId.isEmpty else { return nil }
155
+ let deviceName = dict["deviceName"] ?? "Unknown"
156
+ let dvaiVersion = dict["dvaiVersion"] ?? "0.0.0"
157
+ let portStr = dict["port"] ?? "38883"
158
+ let port = Int(portStr) ?? 38883
159
+ let secure = (dict["secure"] ?? "0") == "1"
160
+
161
+ // Try to extract a host from the endpoint. NWBrowser endpoints
162
+ // are typically `.service(name, type, domain, _)` form; the
163
+ // resolved hostname:port comes through after a connection
164
+ // resolves. For the offload module the baseUrl is the canonical
165
+ // `http://{host}:{port}/v1` once we resolve, but at browse-time
166
+ // we synthesize a placeholder using the bonjour service name.
167
+ let host = bonjourServiceName(from: result.endpoint) ?? "unknown.local"
168
+ let scheme = secure ? "https" : "http"
169
+ let baseUrl = "\(scheme)://\(host):\(port)/v1"
170
+
171
+ let loadedModels = (dict["models"] ?? "").split(separator: ",").map { String($0) }.filter { !$0.isEmpty }
172
+ let capability = parseCapability(dict["capability"]) ?? [:]
173
+
174
+ return MDNSPeer(
175
+ deviceId: deviceId,
176
+ deviceName: deviceName,
177
+ dvaiVersion: dvaiVersion,
178
+ baseUrl: baseUrl,
179
+ loadedModels: loadedModels,
180
+ capability: capability,
181
+ via: .mdns,
182
+ secure: secure,
183
+ lastSeenAt: Int64(Date().timeIntervalSince1970 * 1000)
184
+ )
185
+ }
186
+
187
+ private func bonjourServiceName(from endpoint: NWEndpoint) -> String? {
188
+ if case .service(let name, _, _, _) = endpoint {
189
+ // Bonjour service-instance names sometimes already carry a
190
+ // `.local` suffix when the advertiser uses the device's
191
+ // mDNS hostname (e.g. iPhone advertising as
192
+ // `Deeps-iPhone.local`). Appending another `.local`
193
+ // unconditionally produced URLs like
194
+ // `http://deeps-iphone.local.local:38883` which fail to
195
+ // resolve. Strip a trailing `.local` (and stray trailing
196
+ // dot) BEFORE re-appending so the result is always
197
+ // `<host>.local`.
198
+ var trimmed = name
199
+ if trimmed.hasSuffix(".") { trimmed.removeLast() }
200
+ if trimmed.hasSuffix(".local") {
201
+ trimmed = String(trimmed.dropLast(".local".count))
202
+ }
203
+ return "\(trimmed).local"
204
+ }
205
+ return nil
206
+ }
207
+
208
+ private func parseCapability(_ raw: String?) -> [String: Double]? {
209
+ guard let raw = raw, let data = raw.data(using: .utf8) else { return nil }
210
+ return (try? JSONDecoder().decode([String: Double].self, from: data))
211
+ }
212
+ }
@@ -0,0 +1,59 @@
1
+ import Foundation
2
+
3
+ internal enum BackendSelector {
4
+ /// Resolve `.auto` to a concrete backend; pass-through for explicit choices.
5
+ /// - Throws `DVAIBridgeError.configurationInvalid` if `.auto` can't decide.
6
+ static func resolve(_ kind: BackendKind, config: DVAIBridgeConfig) throws -> BackendKind {
7
+ if kind != .auto { return kind }
8
+
9
+ // 1. modelPath ending in .gguf → .llama
10
+ if let path = config.modelPath, path.hasSuffix(".gguf") {
11
+ return .llama
12
+ }
13
+
14
+ // 2. modelPath ending in .mlmodelc / .mlpackage → .coreml
15
+ if let path = config.modelPath,
16
+ path.hasSuffix(".mlmodelc") || path.hasSuffix(".mlpackage") {
17
+ return .coreml
18
+ }
19
+
20
+ // 3. modelPath ending in .task / .litertlm → no iOS backend supports
21
+ // those; fall through to error
22
+ if let path = config.modelPath,
23
+ path.hasSuffix(".task") || path.hasSuffix(".litertlm") {
24
+ throw DVAIBridgeError.configurationInvalid(reason:
25
+ "Model file '\(path)' is a MediaPipe / LiteRT-LM format. " +
26
+ "Use it via the Android SDK; iOS supports llama.cpp (.gguf), " +
27
+ "Apple Foundation Models (no file), and CoreML (.mlmodelc / .mlpackage).")
28
+ }
29
+
30
+ // 4. No modelPath + iOS 26+ → .foundation
31
+ if config.modelPath == nil {
32
+ if #available(iOS 26.0, macOS 26.0, *) {
33
+ return .foundation
34
+ }
35
+ throw DVAIBridgeError.configurationInvalid(reason:
36
+ "auto backend requires either modelPath (for .llama / .coreml) " +
37
+ "or iOS 26+ (for .foundation). Set DVAIBridgeConfig.backend explicitly.")
38
+ }
39
+
40
+ // 5. modelPath looks like a HuggingFace id ("<owner>/<repo>" with no
41
+ // file extension) → likely MLX. Don't auto-resolve here because
42
+ // not every HF id is MLX (could be GGUF in a HF repo etc.) and
43
+ // .mlx requires Apple Silicon at runtime. Provide a clear hint.
44
+ if let path = config.modelPath,
45
+ path.contains("/"),
46
+ !path.contains(".") {
47
+ throw DVAIBridgeError.configurationInvalid(reason:
48
+ "modelPath '\(path)' looks like a HuggingFace identifier. " +
49
+ "If this is an MLX-converted checkpoint (e.g. 'mlx-community/...'), " +
50
+ "set DVAIBridgeConfig.backend = .mlx explicitly — `.auto` won't " +
51
+ "infer MLX because not every HF id is an MLX checkpoint.")
52
+ }
53
+
54
+ // 6. Unknown extension
55
+ throw DVAIBridgeError.configurationInvalid(reason:
56
+ "auto backend can't infer from modelPath '\(config.modelPath ?? "<nil>")'. " +
57
+ "Set DVAIBridgeConfig.backend = .llama / .foundation / .coreml / .mlx explicitly.")
58
+ }
59
+ }
@@ -0,0 +1,84 @@
1
+ import Foundation
2
+ import Combine
3
+
4
+ /// Internal event broadcaster. Backs three public observation surfaces:
5
+ /// `progressPublisher` (Combine), `progressStream` (AsyncStream), and
6
+ /// `addProgressListener(_:)` (callback). All three observe the same source.
7
+ internal final class ProgressBroadcaster: @unchecked Sendable {
8
+ // Combine
9
+ private let subject = PassthroughSubject<ProgressEvent, Never>()
10
+ var publisher: AnyPublisher<ProgressEvent, Never> { subject.eraseToAnyPublisher() }
11
+
12
+ // AsyncStream — one continuation per consumer
13
+ private let lock = NSLock()
14
+ private var continuations: [UUID: AsyncStream<ProgressEvent>.Continuation] = [:]
15
+
16
+ // Callback — one entry per addProgressListener call
17
+ private var callbacks: [UUID: @Sendable (ProgressEvent) -> Void] = [:]
18
+
19
+ func emit(_ event: ProgressEvent) {
20
+ subject.send(event)
21
+
22
+ lock.lock()
23
+ let conts = continuations.values
24
+ let cbs = Array(callbacks.values)
25
+ lock.unlock()
26
+
27
+ for cont in conts { cont.yield(event) }
28
+ for cb in cbs { cb(event) }
29
+ }
30
+
31
+ func makeStream() -> AsyncStream<ProgressEvent> {
32
+ let id = UUID()
33
+ return AsyncStream { continuation in
34
+ lock.lock()
35
+ continuations[id] = continuation
36
+ lock.unlock()
37
+
38
+ continuation.onTermination = { [weak self] _ in
39
+ self?.lock.lock()
40
+ self?.continuations.removeValue(forKey: id)
41
+ self?.lock.unlock()
42
+ }
43
+ }
44
+ }
45
+
46
+ @discardableResult
47
+ func addCallback(_ cb: @escaping @Sendable (ProgressEvent) -> Void) -> CancellationToken {
48
+ let id = UUID()
49
+ lock.lock()
50
+ callbacks[id] = cb
51
+ lock.unlock()
52
+
53
+ return CancellationToken { [weak self] in
54
+ self?.lock.lock()
55
+ self?.callbacks.removeValue(forKey: id)
56
+ self?.lock.unlock()
57
+ }
58
+ }
59
+ }
60
+
61
+ /// Caller-held token returned by `addProgressListener(_:)`. Drop or call
62
+ /// `.cancel()` to stop receiving events.
63
+ public final class CancellationToken: @unchecked Sendable {
64
+ private let cancelClosure: @Sendable () -> Void
65
+ private var cancelled = false
66
+ private let lock = NSLock()
67
+
68
+ internal init(cancel: @escaping @Sendable () -> Void) {
69
+ self.cancelClosure = cancel
70
+ }
71
+
72
+ public func cancel() {
73
+ lock.lock()
74
+ defer { lock.unlock() }
75
+ if !cancelled {
76
+ cancelled = true
77
+ cancelClosure()
78
+ }
79
+ }
80
+
81
+ deinit {
82
+ cancel()
83
+ }
84
+ }