@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,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
|
+
}
|