@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,98 @@
1
+ import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
5
+
6
+ /// Bundles together the iOS-native offload state for one running
7
+ /// `DVAIBridge` instance: discovery, advertiser, capability cache,
8
+ /// pairing store + policy. Created on `start()` when
9
+ /// `OffloadConfig.enabled == true`; torn down on `stop()`.
10
+ @available(iOS 14.0, macOS 11.0, *)
11
+ public actor OffloadRuntime {
12
+ public let config: OffloadConfig
13
+ public let supportDirectory: URL
14
+ public let deviceIDStore: DeviceIDStore
15
+ public let capabilityCache: CapabilityCache
16
+ public let pairingStore: PairingStore
17
+ public let pairingPolicy: PairingPolicy
18
+ public let discovery: NWBrowserDiscovery
19
+ public let advertiser: NWAdvertiser
20
+ private var started = false
21
+
22
+ public init(config: OffloadConfig, supportDirectory: URL? = nil) throws {
23
+ self.config = config
24
+ self.supportDirectory = try supportDirectory ?? DVAIBridgeSupportDirectory.resolve()
25
+ self.deviceIDStore = DeviceIDStore(directory: self.supportDirectory)
26
+ self.capabilityCache = CapabilityCache(directory: self.supportDirectory)
27
+ self.pairingStore = PairingStore(directory: self.supportDirectory)
28
+ self.pairingPolicy = PairingPolicy(
29
+ store: pairingStore,
30
+ expireAfterDays: config.expireAfterDays
31
+ )
32
+ self.discovery = NWBrowserDiscovery()
33
+ self.advertiser = NWAdvertiser()
34
+ }
35
+
36
+ /// Bring up the discovery + advertiser. Idempotent.
37
+ /// `boundServer` provides the port + version we advertise.
38
+ public func start(boundServer: BoundServer, libraryVersion: String) async throws {
39
+ guard !started else { return }
40
+ started = true
41
+
42
+ if config.discoverLAN {
43
+ let deviceId = try deviceIDStore.get()
44
+ // Configure self-filtering BEFORE starting the browser
45
+ // so the very first peerUp event from our own
46
+ // advertisement is dropped (race-free).
47
+ await discovery.setSelfDeviceId(deviceId)
48
+ // Defensive: drop any stale self-pairing that an earlier
49
+ // build may have persisted before the self-filter
50
+ // existed. A pairing keyed by our own deviceId would
51
+ // otherwise let `decideRoute`'s paired-peer fallback
52
+ // route requests back to us in an infinite loop.
53
+ try? await pairingStore.remove(deviceId)
54
+ await discovery.start()
55
+ let deviceName = await Self.resolveDeviceName()
56
+ try await advertiser.start(
57
+ NWAdvertiser.Advertisement(
58
+ deviceId: deviceId,
59
+ deviceName: deviceName,
60
+ dvaiVersion: libraryVersion,
61
+ port: boundServer.port,
62
+ secure: false,
63
+ loadedModels: boundServer.modelId.isEmpty ? [] : [boundServer.modelId],
64
+ capability: [:]
65
+ )
66
+ )
67
+ }
68
+ }
69
+
70
+ /// Tear down. Idempotent.
71
+ public func stop() async {
72
+ guard started else { return }
73
+ started = false
74
+ await discovery.stop()
75
+ await advertiser.stop()
76
+ await pairingPolicy.shutdown()
77
+ }
78
+
79
+ public func isRunning() -> Bool { started }
80
+
81
+ /// Surface the pairing-request stream. Called by
82
+ /// `DVAIBridge.shared.pairingRequests`.
83
+ public nonisolated func pairingRequestStream() -> AsyncStream<PairingRequest> {
84
+ pairingPolicy.requestStream
85
+ }
86
+
87
+ public nonisolated func discoveryEventStream() -> AsyncStream<NWBrowserDiscovery.Event> {
88
+ discovery.events
89
+ }
90
+
91
+ private static func resolveDeviceName() async -> String {
92
+ #if canImport(UIKit) && !os(macOS)
93
+ return await MainActor.run { UIDevice.current.name }
94
+ #else
95
+ return ProcessInfo.processInfo.hostName
96
+ #endif
97
+ }
98
+ }
@@ -0,0 +1,125 @@
1
+ import Foundation
2
+
3
+ /// A "pairing" is an authenticated trust relationship between two
4
+ /// devices, established once via the handshake flow then reused for
5
+ /// all subsequent offload requests via HMAC-signed headers. Mirrors
6
+ /// the TS shape in `packages/dvai-bridge-core/src/pairing/types.ts`.
7
+ public struct Pairing: Sendable, Equatable, Codable, Hashable {
8
+ public enum Via: String, Sendable, Codable, Hashable {
9
+ case lanHandshake = "lan-handshake"
10
+ case rendezvousQR = "rendezvous-qr"
11
+ }
12
+
13
+ /// Stable per-install device ID of the peer.
14
+ public let peerDeviceId: String
15
+ /// Friendly name for the user UI.
16
+ public let peerDeviceName: String
17
+ /// Shared 256-bit pairing key (base64-url encoded). Used for HMAC.
18
+ public let pairingKey: String
19
+ /// When the pairing was first established (unix ms).
20
+ public let pairedAt: Int64
21
+ /// Last time this pairing was used (unix ms).
22
+ public var lastUsedAt: Int64
23
+ /// Pairing source — informational.
24
+ public let via: Via
25
+ /// v3.2.1 — last-known peer baseUrl. Captured at pairing time
26
+ /// so the OffloadProxy can route to a paired peer even when it
27
+ /// isn't currently in the mDNS discovery list (e.g. the desktop
28
+ /// Hub on macOS, where Node's `multicast-dns` lib can't
29
+ /// advertise through mDNSResponder). Optional for back-compat —
30
+ /// pairings persisted by earlier builds decode with `nil` here.
31
+ public var baseUrl: String?
32
+
33
+ public init(
34
+ peerDeviceId: String,
35
+ peerDeviceName: String,
36
+ pairingKey: String,
37
+ pairedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000),
38
+ lastUsedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000),
39
+ via: Via = .lanHandshake,
40
+ baseUrl: String? = nil
41
+ ) {
42
+ self.peerDeviceId = peerDeviceId
43
+ self.peerDeviceName = peerDeviceName
44
+ self.pairingKey = pairingKey
45
+ self.pairedAt = pairedAt
46
+ self.lastUsedAt = lastUsedAt
47
+ self.via = via
48
+ self.baseUrl = baseUrl
49
+ }
50
+ }
51
+
52
+ /// Surfaced to the host app as an item in the
53
+ /// `DVAIBridge.shared.pairingRequests()` AsyncStream. The host app
54
+ /// decides approve/deny, then calls `respond(approved:)`.
55
+ ///
56
+ /// If the host doesn't call `respond(approved:)` within the policy's
57
+ /// timeout, the request defaults to deny — see `PairingPolicy`.
58
+ public final class PairingRequest: @unchecked Sendable {
59
+ public let peerDeviceId: String
60
+ public let peerDeviceName: String
61
+ public let via: Pairing.Via
62
+
63
+ private let lock = NSLock()
64
+ private var responded = false
65
+ private var pendingValue: Bool?
66
+ /// Continuation used by the policy to await the host's respond.
67
+ /// Lazily set when `awaitResponse()` is called the first time.
68
+ private var awaitContinuation: CheckedContinuation<Bool, Never>?
69
+
70
+ public init(
71
+ peerDeviceId: String,
72
+ peerDeviceName: String,
73
+ via: Pairing.Via
74
+ ) {
75
+ self.peerDeviceId = peerDeviceId
76
+ self.peerDeviceName = peerDeviceName
77
+ self.via = via
78
+ }
79
+
80
+ /// Host-app entry point. Approve or deny the pairing.
81
+ public func respond(approved: Bool) {
82
+ lock.lock()
83
+ if responded {
84
+ lock.unlock()
85
+ return
86
+ }
87
+ responded = true
88
+ if let cont = awaitContinuation {
89
+ awaitContinuation = nil
90
+ lock.unlock()
91
+ cont.resume(returning: approved)
92
+ } else {
93
+ // No one's awaiting yet — store the value for whoever
94
+ // calls awaitResponse() next.
95
+ pendingValue = approved
96
+ lock.unlock()
97
+ }
98
+ }
99
+
100
+ /// Internal — used by `PairingPolicy` to await the host response.
101
+ /// Resumes when `respond(approved:)` is called or returns `false`
102
+ /// when `cancelWithDeny()` is called.
103
+ internal func awaitResponse() async -> Bool {
104
+ return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
105
+ lock.lock()
106
+ if let stored = pendingValue {
107
+ pendingValue = nil
108
+ lock.unlock()
109
+ cont.resume(returning: stored)
110
+ } else if responded {
111
+ lock.unlock()
112
+ cont.resume(returning: false)
113
+ } else {
114
+ awaitContinuation = cont
115
+ lock.unlock()
116
+ }
117
+ }
118
+ }
119
+
120
+ /// Internal — fast-path deny used by the policy on timeout / stream
121
+ /// teardown / drop.
122
+ internal func cancelWithDeny() {
123
+ respond(approved: false)
124
+ }
125
+ }
@@ -0,0 +1,141 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ /// Pairing-handshake primitives: HMAC-SHA256 signing of offload
5
+ /// requests + helpers to compose the canonical signed message. Mirrors
6
+ /// `packages/dvai-bridge-core/src/pairing/handshake.ts` byte-for-byte
7
+ /// so signatures round-trip between iOS and other peers (Node, Android,
8
+ /// browser).
9
+ ///
10
+ /// All keys / nonces / signatures are encoded in URL-safe base64
11
+ /// without padding — same as the TS side's `encodeBase64Url`.
12
+ public enum PairingHandshake {
13
+
14
+ // MARK: - Public API
15
+
16
+ /// Generate a fresh 256-bit pairing key (base64-url, no padding).
17
+ public static func generatePairingKey() -> String {
18
+ var bytes = [UInt8](repeating: 0, count: 32)
19
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
20
+ return base64UrlEncode(Data(bytes))
21
+ }
22
+
23
+ /// Generate a fresh nonce for a handshake request (16 bytes).
24
+ public static func generateNonce() -> String {
25
+ var bytes = [UInt8](repeating: 0, count: 16)
26
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
27
+ return base64UrlEncode(Data(bytes))
28
+ }
29
+
30
+ /// HMAC-SHA256(pairingKey, message) → base64-url encoded.
31
+ public static func signHmac(pairingKey: String, message: String) throws -> String {
32
+ let keyData = try decodeBase64Url(pairingKey)
33
+ let key = SymmetricKey(data: keyData)
34
+ let messageData = Data(message.utf8)
35
+ let mac = HMAC<SHA256>.authenticationCode(for: messageData, using: key)
36
+ return base64UrlEncode(Data(mac))
37
+ }
38
+
39
+ /// Constant-time-ish HMAC verify. Returns true on match.
40
+ public static func verifyHmac(
41
+ pairingKey: String,
42
+ message: String,
43
+ signature: String
44
+ ) throws -> Bool {
45
+ let expected = try signHmac(pairingKey: pairingKey, message: message)
46
+ return constantTimeEquals(expected, signature)
47
+ }
48
+
49
+ /// Compose the canonical message that gets HMAC-signed for a
50
+ /// peer-to-peer offload request. Mirrors
51
+ /// `composeSignedMessage` in handshake.ts:
52
+ ///
53
+ /// `${nonce}\n${METHOD}\n${path}\n${bodyHash}`
54
+ ///
55
+ /// where bodyHash is the hex-encoded SHA-256 of the request body
56
+ /// bytes (or all-zero hex for empty body).
57
+ public static func composeSignedMessage(
58
+ nonce: String,
59
+ method: String,
60
+ path: String,
61
+ body: String?
62
+ ) -> String {
63
+ let bodyHash: String
64
+ if let body = body, !body.isEmpty {
65
+ bodyHash = sha256Hex(body)
66
+ } else {
67
+ bodyHash = String(repeating: "0", count: 64)
68
+ }
69
+ return "\(nonce)\n\(method.uppercased())\n\(path)\n\(bodyHash)"
70
+ }
71
+
72
+ /// Hex-encoded SHA-256 of the input string. Public for tests.
73
+ public static func sha256Hex(_ input: String) -> String {
74
+ let digest = SHA256.hash(data: Data(input.utf8))
75
+ return digest.map { String(format: "%02x", $0) }.joined()
76
+ }
77
+
78
+ // MARK: - Base64-url helpers
79
+
80
+ /// URL-safe base64 encode without padding. Public for tests.
81
+ public static func base64UrlEncode(_ data: Data) -> String {
82
+ let b64 = data.base64EncodedString()
83
+ return b64
84
+ .replacingOccurrences(of: "+", with: "-")
85
+ .replacingOccurrences(of: "/", with: "_")
86
+ .replacingOccurrences(of: "=", with: "")
87
+ }
88
+
89
+ /// Decode URL-safe base64 (with or without padding). Public for tests.
90
+ public static func decodeBase64Url(_ s: String) throws -> Data {
91
+ var b64 = s
92
+ .replacingOccurrences(of: "-", with: "+")
93
+ .replacingOccurrences(of: "_", with: "/")
94
+ let padding = (4 - (b64.count % 4)) % 4
95
+ b64 += String(repeating: "=", count: padding)
96
+ guard let data = Data(base64Encoded: b64) else {
97
+ throw DVAIBridgeError.configurationInvalid(reason: "invalid base64-url string")
98
+ }
99
+ return data
100
+ }
101
+
102
+ private static func constantTimeEquals(_ a: String, _ b: String) -> Bool {
103
+ let aBytes = Array(a.utf8)
104
+ let bBytes = Array(b.utf8)
105
+ if aBytes.count != bBytes.count { return false }
106
+ var diff: UInt8 = 0
107
+ for i in 0..<aBytes.count {
108
+ diff |= aBytes[i] ^ bBytes[i]
109
+ }
110
+ return diff == 0
111
+ }
112
+ }
113
+
114
+ /// HandshakeRequest body the offload-source POSTs to the target's
115
+ /// `/v1/dvai/handshake` endpoint. Matches the TS shape.
116
+ public struct HandshakeRequest: Sendable, Equatable, Codable {
117
+ public let originDeviceId: String
118
+ public let originDeviceName: String
119
+ public let originVersion: String
120
+ public let nonce: String
121
+
122
+ public init(originDeviceId: String, originDeviceName: String, originVersion: String, nonce: String) {
123
+ self.originDeviceId = originDeviceId
124
+ self.originDeviceName = originDeviceName
125
+ self.originVersion = originVersion
126
+ self.nonce = nonce
127
+ }
128
+ }
129
+
130
+ /// HandshakeResponse the target returns. Matches the TS shape.
131
+ public struct HandshakeResponse: Sendable, Equatable, Codable {
132
+ public let approved: Bool
133
+ public let pairingKey: String?
134
+ public let reason: String?
135
+
136
+ public init(approved: Bool, pairingKey: String? = nil, reason: String? = nil) {
137
+ self.approved = approved
138
+ self.pairingKey = pairingKey
139
+ self.reason = reason
140
+ }
141
+ }
@@ -0,0 +1,162 @@
1
+ import Foundation
2
+
3
+ /// Coordinates the host-app's pairing-request UI with the persistent
4
+ /// `PairingStore`. Mirrors `packages/dvai-bridge-core/src/pairing/policy.ts`.
5
+ ///
6
+ /// The host app consumes the request stream via
7
+ /// `DVAIBridge.shared.pairingRequests`. If nothing is consuming the
8
+ /// stream when a request comes in, the policy denies — same safe
9
+ /// fallback as the JS side's default-deny.
10
+ public actor PairingPolicy {
11
+ /// Default time the policy waits for the host app to respond to a
12
+ /// `PairingRequest` before defaulting to deny. 30 seconds is the
13
+ /// same magnitude as a typical OS permission prompt — long enough
14
+ /// for the user to read + decide, short enough that an unattended
15
+ /// device doesn't leak approval to a malicious peer.
16
+ public static let defaultResponseTimeoutSeconds: Double = 30
17
+
18
+ private let store: PairingStore
19
+ private let expireAfterDays: Int
20
+ private let responseTimeoutSeconds: Double
21
+ private let continuation: AsyncStream<PairingRequest>.Continuation
22
+ /// AsyncStream of pairing requests. Lifecycle-bound to this policy
23
+ /// instance; finishes when `shutdown()` is called.
24
+ public nonisolated let requestStream: AsyncStream<PairingRequest>
25
+
26
+ public init(
27
+ store: PairingStore,
28
+ expireAfterDays: Int = 30,
29
+ responseTimeoutSeconds: Double = PairingPolicy.defaultResponseTimeoutSeconds
30
+ ) {
31
+ self.store = store
32
+ self.expireAfterDays = expireAfterDays
33
+ self.responseTimeoutSeconds = responseTimeoutSeconds
34
+ var savedContinuation: AsyncStream<PairingRequest>.Continuation!
35
+ self.requestStream = AsyncStream<PairingRequest> { c in
36
+ savedContinuation = c
37
+ }
38
+ self.continuation = savedContinuation
39
+ }
40
+
41
+ /// Snapshot of every persisted pairing (active or expired).
42
+ /// Used by the OffloadProxy's paired-peer fallback to consider
43
+ /// pairings whose peers aren't currently in mDNS discovery.
44
+ public func listPairings() async -> [Pairing] {
45
+ await store.list()
46
+ }
47
+
48
+ /// Active (non-expired) pairing for this peer, or nil.
49
+ public func getActive(peerDeviceId: String) async -> Pairing? {
50
+ guard let existing = await store.get(peerDeviceId) else { return nil }
51
+ let ttlMs = Int64(expireAfterDays) * 24 * 60 * 60 * 1000
52
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
53
+ if nowMs - existing.lastUsedAt > ttlMs {
54
+ try? await store.remove(peerDeviceId)
55
+ return nil
56
+ }
57
+ return existing
58
+ }
59
+
60
+ /// Process an incoming pairing request. If we already have an
61
+ /// active pairing for this peer, reuse it (and bump lastUsedAt).
62
+ /// Otherwise yield a `PairingRequest` to the host-app stream and
63
+ /// await their decision.
64
+ public func approveOrFetch(
65
+ peerDeviceId: String,
66
+ peerDeviceName: String,
67
+ via: Pairing.Via
68
+ ) async throws -> Pairing {
69
+ if var existing = await getActive(peerDeviceId: peerDeviceId) {
70
+ existing.lastUsedAt = Int64(Date().timeIntervalSince1970 * 1000)
71
+ try await store.set(existing)
72
+ return existing
73
+ }
74
+
75
+ let approved = await requestApproval(
76
+ peerDeviceId: peerDeviceId,
77
+ peerDeviceName: peerDeviceName,
78
+ via: via
79
+ )
80
+ if !approved {
81
+ throw DVAIBridgeError.configurationInvalid(
82
+ reason: "[DVAI/pairing] denied: peer \(peerDeviceId) (\(peerDeviceName))"
83
+ )
84
+ }
85
+
86
+ let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
87
+ let pairing = Pairing(
88
+ peerDeviceId: peerDeviceId,
89
+ peerDeviceName: peerDeviceName,
90
+ pairingKey: PairingHandshake.generatePairingKey(),
91
+ pairedAt: nowMs,
92
+ lastUsedAt: nowMs,
93
+ via: via
94
+ )
95
+ try await store.set(pairing)
96
+ return pairing
97
+ }
98
+
99
+ /// Bump lastUsedAt for an existing pairing.
100
+ public func touch(peerDeviceId: String) async throws {
101
+ guard var existing = await store.get(peerDeviceId) else { return }
102
+ existing.lastUsedAt = Int64(Date().timeIntervalSince1970 * 1000)
103
+ try await store.set(existing)
104
+ }
105
+
106
+ public func revoke(peerDeviceId: String) async throws {
107
+ try await store.remove(peerDeviceId)
108
+ }
109
+
110
+ /// Tear down the request stream. Called on `DVAIBridge.shared.stop()`.
111
+ public func shutdown() {
112
+ continuation.finish()
113
+ }
114
+
115
+ /// Surface a request to the host app and await their respond(:)
116
+ /// call. If the host doesn't respond within
117
+ /// `responseTimeoutSeconds`, the request defaults to deny — same
118
+ /// safe fallback as the JS side when no `onPairingRequest` callback
119
+ /// is supplied.
120
+ private func requestApproval(
121
+ peerDeviceId: String,
122
+ peerDeviceName: String,
123
+ via: Pairing.Via
124
+ ) async -> Bool {
125
+ // Race the host-app response against a timeout.
126
+ return await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
127
+ let req = PairingRequest(
128
+ peerDeviceId: peerDeviceId,
129
+ peerDeviceName: peerDeviceName,
130
+ via: via
131
+ )
132
+ let yieldResult = continuation.yield(req)
133
+ switch yieldResult {
134
+ case .enqueued:
135
+ break
136
+ case .terminated, .dropped:
137
+ // Stream is gone or buffer dropped the value — deny.
138
+ req.cancelWithDeny()
139
+ return false
140
+ @unknown default:
141
+ req.cancelWithDeny()
142
+ return false
143
+ }
144
+ // Consumer task: await the host's respond() call.
145
+ group.addTask {
146
+ await req.awaitResponse()
147
+ }
148
+ // Timeout task: if host hasn't responded in time, deny.
149
+ let timeoutSeconds = self.responseTimeoutSeconds
150
+ group.addTask {
151
+ let nanos = UInt64(timeoutSeconds * 1_000_000_000)
152
+ try? await Task.sleep(nanoseconds: nanos)
153
+ req.cancelWithDeny()
154
+ return false
155
+ }
156
+ // First completion wins.
157
+ let first = await group.next() ?? false
158
+ group.cancelAll()
159
+ return first
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,65 @@
1
+ import Foundation
2
+
3
+ /// JSON-file-backed pairing store under
4
+ /// `Application Support/dvai-bridge/pairings.json`. Mirrors the TS-side
5
+ /// `NodeFsPairingStore` in `packages/dvai-bridge-core/src/pairing/store.ts`.
6
+ public actor PairingStore {
7
+ private let fileURL: URL
8
+ private var cache: [String: Pairing]?
9
+
10
+ public init(directory: URL) {
11
+ self.fileURL = directory.appendingPathComponent("pairings.json", isDirectory: false)
12
+ }
13
+
14
+ public func get(_ peerDeviceId: String) async -> Pairing? {
15
+ let map = await load()
16
+ return map[peerDeviceId]
17
+ }
18
+
19
+ public func set(_ pairing: Pairing) async throws {
20
+ var map = await load()
21
+ map[pairing.peerDeviceId] = pairing
22
+ cache = map
23
+ try await save()
24
+ }
25
+
26
+ public func list() async -> [Pairing] {
27
+ let map = await load()
28
+ return Array(map.values)
29
+ }
30
+
31
+ public func remove(_ peerDeviceId: String) async throws {
32
+ var map = await load()
33
+ map.removeValue(forKey: peerDeviceId)
34
+ cache = map
35
+ try await save()
36
+ }
37
+
38
+ public func clear() async throws {
39
+ cache = [:]
40
+ try await save()
41
+ }
42
+
43
+ private func load() async -> [String: Pairing] {
44
+ if let cache = cache { return cache }
45
+ if let data = try? Data(contentsOf: fileURL),
46
+ let decoded = try? JSONDecoder().decode([String: Pairing].self, from: data) {
47
+ cache = decoded
48
+ return decoded
49
+ }
50
+ cache = [:]
51
+ return [:]
52
+ }
53
+
54
+ private func save() async throws {
55
+ guard let cache = cache else { return }
56
+ try FileManager.default.createDirectory(
57
+ at: fileURL.deletingLastPathComponent(),
58
+ withIntermediateDirectories: true
59
+ )
60
+ let encoder = JSONEncoder()
61
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
62
+ let data = try encoder.encode(cache)
63
+ try data.write(to: fileURL, options: .atomic)
64
+ }
65
+ }
@@ -0,0 +1,34 @@
1
+ import Foundation
2
+
3
+ /// Lifecycle progress event emitted during start(), downloadModel(), and
4
+ /// related long-running operations. Mirrors the existing TS / Capacitor
5
+ /// `ProgressEvent` shape so the iOS SDK reads identically to the JS API.
6
+ public struct ProgressEvent: Sendable, Equatable, Codable {
7
+ public enum Phase: String, Sendable, Codable {
8
+ case download
9
+ case verify
10
+ case load
11
+ case ready
12
+ case error
13
+ }
14
+
15
+ public let phase: Phase
16
+ public let bytesReceived: Int64?
17
+ public let bytesTotal: Int64?
18
+ public let percent: Double?
19
+ public let message: String?
20
+
21
+ public init(
22
+ phase: Phase,
23
+ bytesReceived: Int64? = nil,
24
+ bytesTotal: Int64? = nil,
25
+ percent: Double? = nil,
26
+ message: String? = nil
27
+ ) {
28
+ self.phase = phase
29
+ self.bytesReceived = bytesReceived
30
+ self.bytesTotal = bytesTotal
31
+ self.percent = percent
32
+ self.message = message
33
+ }
34
+ }