@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.
- package/DVAIBridge.podspec +120 -0
- package/LICENSE +51 -0
- package/Package.swift +104 -0
- package/README.md +199 -0
- package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
- package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
- package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
- package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
- package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
- package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
- package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
- package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
- package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
- package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
- package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
- package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
- package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
- package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
- package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
- package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
- package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
- package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
- package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
- package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
- package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
- package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
- package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
- package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
- package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
- package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
- 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
|
+
}
|