@mentra/bluetooth-sdk 3.4.0-dev.978 → 3.4.0-dev.979
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/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/build/_private/BluetoothSdkModule.d.ts +3 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +6 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +15 -2
- package/ios/Source/sgcs/Nimo.swift +278 -38
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +77 -18
- package/ios/Source/sgcs/NimoCanvasSession.swift +1 -0
- package/ios/Source/sgcs/NimoMacResourceTransport.swift +217 -0
- package/ios/Source/sgcs/NimoResourceFixture.swift +187 -0
- package/ios/Source/sgcs/NimoResourceSession.swift +340 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +75 -2
- package/ios/Tests/NimoCanvasTransportTests.swift +147 -0
- package/ios/Tests/NimoResourceSessionTests.swift +348 -0
- package/package.json +2 -2
- package/src/_private/BluetoothSdkModule.ts +3 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -7,40 +7,49 @@ final class NimoCanvasCoordinator {
|
|
|
7
7
|
private final class Flight {
|
|
8
8
|
let key: Int
|
|
9
9
|
var finalStarted = false
|
|
10
|
+
var finalStartedElapsedMs: UInt64?
|
|
10
11
|
var complete = false
|
|
11
|
-
var earlyAck: Data?
|
|
12
|
+
var earlyAck: (Data, UInt64?)?
|
|
12
13
|
init(key: Int) {
|
|
13
14
|
self.key = key
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
private let continuousNow: (() -> UInt64)?
|
|
17
19
|
private let schedule: NimoScheduler
|
|
18
20
|
private let writeCapacity: () -> Int
|
|
19
21
|
private let enqueue: ([Data], @escaping () -> Void, @escaping () -> Void) -> Bool
|
|
20
22
|
private let reconnect: (String) -> Void
|
|
21
23
|
private let rejected: (Int) -> Void
|
|
24
|
+
private let result: (Int, Int) -> Void
|
|
25
|
+
private let observedResult: (Int, Int, UInt64?) -> Void
|
|
22
26
|
private let session = NimoCanvasSession()
|
|
23
27
|
private var flight: Flight?
|
|
28
|
+
private var releaseDraining = false
|
|
24
29
|
private var cancelRetry: (() -> Void)?
|
|
25
30
|
private var cancelDeadline: (() -> Void)?
|
|
26
31
|
|
|
27
32
|
init(schedule: @escaping NimoScheduler, writeCapacity: @escaping () -> Int,
|
|
28
33
|
enqueue: @escaping ([Data], @escaping () -> Void, @escaping () -> Void) -> Bool,
|
|
29
|
-
reconnect: @escaping (String) -> Void, rejected: @escaping (Int) -> Void)
|
|
34
|
+
reconnect: @escaping (String) -> Void, rejected: @escaping (Int) -> Void, result: @escaping (Int, Int) -> Void = { _, _ in }, observedResult: @escaping (Int, Int, UInt64?) -> Void = { _, _, _ in }, continuousNow: (() -> UInt64)? = nil)
|
|
30
35
|
{
|
|
36
|
+
self.continuousNow = continuousNow
|
|
31
37
|
self.schedule = schedule; self.writeCapacity = writeCapacity; self.enqueue = enqueue
|
|
32
|
-
self.reconnect = reconnect; self.rejected = rejected
|
|
38
|
+
self.reconnect = reconnect; self.rejected = rejected; self.result = result; self.observedResult = observedResult
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
func offer(_ bytes: Data, scope: String, force: Bool = false) {
|
|
42
|
+
guard !releaseDraining else { return }
|
|
36
43
|
run(session.offer(bytes, scope: scope, force: force))
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
func activate() {
|
|
47
|
+
guard !releaseDraining else { return }
|
|
40
48
|
run(session.activate())
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
func readiness(_ value: Bool, confirmed: Bool = false) {
|
|
52
|
+
guard !releaseDraining else { return }
|
|
44
53
|
if !value { cancelRetry?(); cancelRetry = nil }
|
|
45
54
|
run(session.readiness(value, confirmed: confirmed))
|
|
46
55
|
}
|
|
@@ -50,7 +59,20 @@ final class NimoCanvasCoordinator {
|
|
|
50
59
|
run(session.exit())
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
/// Fence new scenes/retries while retaining only the already-issued command
|
|
63
|
+
/// and its exact ACK, followed by the final Exit through the same queue.
|
|
64
|
+
func drainForRelease() {
|
|
65
|
+
releaseDraining = true
|
|
66
|
+
cancelRetry?(); cancelRetry = nil
|
|
67
|
+
run(session.exit())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func acceptsReleaseResponse(key: Int, payload: Data) -> Bool {
|
|
71
|
+
releaseDraining && flight?.key == key && session.acceptsResponse(key: key, payload: payload)
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
func nativeApp(_ appId: Int, entered: Bool) {
|
|
75
|
+
guard !releaseDraining else { return }
|
|
54
76
|
run(session.nativeApp(appId, entered: entered))
|
|
55
77
|
}
|
|
56
78
|
|
|
@@ -61,17 +83,24 @@ final class NimoCanvasCoordinator {
|
|
|
61
83
|
session.disconnected()
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
func response(key: Int, payload: Data) {
|
|
86
|
+
func response(key: Int, payload: Data, receivedElapsedMs: UInt64? = nil) {
|
|
65
87
|
guard let current = flight, session.acceptsResponse(key: key, payload: payload) else { return }
|
|
88
|
+
if let receivedElapsedMs, let started = current.finalStartedElapsedMs, receivedElapsedMs < started { return }
|
|
66
89
|
if !current.complete {
|
|
67
|
-
if current.finalStarted, current.earlyAck == nil { current.earlyAck = payload }
|
|
90
|
+
if current.finalStarted, current.earlyAck == nil { current.earlyAck = (payload, receivedElapsedMs) }
|
|
68
91
|
return
|
|
69
92
|
}
|
|
70
93
|
cancelDeadline?(); cancelDeadline = nil
|
|
71
94
|
flight = nil
|
|
72
|
-
|
|
95
|
+
let actions = session.response(key: key, payload: payload)
|
|
96
|
+
result(key, Int(payload.first ?? 255))
|
|
97
|
+
observedResult(key, Int(payload.first ?? 255), receivedElapsedMs)
|
|
98
|
+
run(actions)
|
|
73
99
|
}
|
|
74
100
|
|
|
101
|
+
var pendingCount: Int { (flight == nil ? 0 : 1) + (cancelRetry == nil ? 0 : 1) }
|
|
102
|
+
var mayHaveEntered: Bool { session.mayHaveEntered }
|
|
103
|
+
|
|
75
104
|
private func run(_ actions: [NimoCanvasSession.Action]) {
|
|
76
105
|
for action in actions {
|
|
77
106
|
switch action {
|
|
@@ -87,11 +116,11 @@ final class NimoCanvasCoordinator {
|
|
|
87
116
|
do {
|
|
88
117
|
let frames = try NimoCanvasCodec.frames(key: key, content: frame, writeCapacity: writeCapacity())
|
|
89
118
|
let queued = enqueue(frames, { [weak self] in
|
|
90
|
-
if self
|
|
119
|
+
if let self, self.flight === current { current.finalStarted = true; current.finalStartedElapsedMs = self.continuousNow?() }
|
|
91
120
|
}, { [weak self] in
|
|
92
121
|
guard let self, self.flight === current else { return }
|
|
93
122
|
current.complete = true
|
|
94
|
-
if let ack = current.earlyAck { self.response(key: key, payload: ack) }
|
|
123
|
+
if let ack = current.earlyAck { self.response(key: key, payload: ack.0, receivedElapsedMs: ack.1) }
|
|
95
124
|
})
|
|
96
125
|
if !queued, flight === current { disconnected(); reconnect("Canvas transport unavailable") }
|
|
97
126
|
} catch {
|
|
@@ -100,7 +129,7 @@ final class NimoCanvasCoordinator {
|
|
|
100
129
|
case let .reconnect(reason): disconnected(); reconnect(reason)
|
|
101
130
|
case let .rejected(status):
|
|
102
131
|
rejected(status)
|
|
103
|
-
if status == 7 {
|
|
132
|
+
if status == 7 && !releaseDraining {
|
|
104
133
|
cancelRetry?()
|
|
105
134
|
cancelRetry = schedule(1) { [weak self] in
|
|
106
135
|
guard let self else { return }
|
|
@@ -134,6 +163,11 @@ final class NimoWriteQueue<Characteristic: AnyObject> {
|
|
|
134
163
|
private var generation: UInt64 = 0
|
|
135
164
|
private var queue: [Write] = []
|
|
136
165
|
private var current: Write?
|
|
166
|
+
private var outstanding: [(AnyObject, Characteristic)] = []
|
|
167
|
+
private var scheduledCompletions = 0
|
|
168
|
+
var activeCount: Int { outstanding.count + scheduledCompletions }
|
|
169
|
+
var queuedCount: Int { queue.count }
|
|
170
|
+
func connectionClosed(_ closed: AnyObject) { outstanding.removeAll { $0.0 === closed } }
|
|
137
171
|
private var cancelDeadline: (() -> Void)?
|
|
138
172
|
|
|
139
173
|
init(schedule: @escaping NimoScheduler, write: @escaping (Characteristic, Data) -> Bool, failure: @escaping (String) -> Void) {
|
|
@@ -165,14 +199,18 @@ final class NimoWriteQueue<Characteristic: AnyObject> {
|
|
|
165
199
|
}
|
|
166
200
|
|
|
167
201
|
func written(_ connection: AnyObject, characteristic: Characteristic, success: Bool) {
|
|
202
|
+
if let index = outstanding.firstIndex(where: { $0.0 === connection && $0.1 === characteristic }) { outstanding.remove(at: index) }
|
|
168
203
|
guard connection === self.connection, let item = current,
|
|
169
204
|
characteristic === item.characteristic, !item.callbackReceived else { return }
|
|
170
205
|
item.callbackReceived = true
|
|
171
206
|
cancelDeadline?(); cancelDeadline = nil
|
|
172
207
|
guard success else { fail("GATT write failed"); return }
|
|
173
208
|
let expected = generation
|
|
209
|
+
scheduledCompletions += 1
|
|
174
210
|
_ = schedule(0.005) { [weak self] in
|
|
175
|
-
guard let self
|
|
211
|
+
guard let self else { return }
|
|
212
|
+
self.scheduledCompletions -= 1
|
|
213
|
+
guard self.generation == expected, self.current === item else { return }
|
|
176
214
|
self.current = nil
|
|
177
215
|
item.completed()
|
|
178
216
|
self.drain()
|
|
@@ -185,6 +223,7 @@ final class NimoWriteQueue<Characteristic: AnyObject> {
|
|
|
185
223
|
current = item
|
|
186
224
|
let expected = generation
|
|
187
225
|
guard write(item.characteristic, item.bytes) else { fail("GATT transport unavailable"); return }
|
|
226
|
+
outstanding.append((connection!, item.characteristic))
|
|
188
227
|
item.started()
|
|
189
228
|
cancelDeadline = schedule(3) { [weak self] in
|
|
190
229
|
guard let self, self.generation == expected, self.current === item, !item.callbackReceived else { return }
|
|
@@ -199,26 +238,41 @@ final class NimoWriteQueue<Characteristic: AnyObject> {
|
|
|
199
238
|
|
|
200
239
|
/// Coalesces pending encodes and fences running results on scene change, Exit and disconnect.
|
|
201
240
|
final class NimoCanvasEncoder {
|
|
241
|
+
private final class Work {
|
|
242
|
+
enum Stage { case queued, worker, delivery, settled }
|
|
243
|
+
var stage = Stage.queued
|
|
244
|
+
var cancelled = false
|
|
245
|
+
}
|
|
202
246
|
private let worker: NimoScheduler
|
|
203
247
|
private let main: NimoScheduler
|
|
204
248
|
private let failure: (Error) -> Void
|
|
249
|
+
private let lock = NSLock()
|
|
205
250
|
private var generation: UInt64 = 0
|
|
206
|
-
private var
|
|
251
|
+
private var jobs: [Work] = []
|
|
252
|
+
var workerCount: Int { lock.lock(); defer { lock.unlock() }; return jobs.filter { $0.stage == .queued || $0.stage == .worker }.count }
|
|
253
|
+
var deliveryCount: Int { lock.lock(); defer { lock.unlock() }; return jobs.filter { $0.stage == .delivery }.count }
|
|
207
254
|
|
|
208
255
|
init(main: @escaping NimoScheduler, worker: @escaping NimoScheduler, failure: @escaping (Error) -> Void) {
|
|
209
256
|
self.main = main; self.worker = worker; self.failure = failure
|
|
210
257
|
}
|
|
211
258
|
|
|
212
|
-
/// submit/invalidate and delivery run on Main; the worker only reads its captured request.
|
|
213
259
|
func submit(encode: @escaping () throws -> Data, deliver: @escaping (Data) -> Void) {
|
|
214
260
|
invalidate()
|
|
215
|
-
let expected = generation
|
|
216
|
-
|
|
217
|
-
|
|
261
|
+
let expected = generation, job = Work()
|
|
262
|
+
lock.lock(); jobs.append(job); lock.unlock()
|
|
263
|
+
_ = worker(0) { [weak self] in
|
|
218
264
|
guard let self else { return }
|
|
265
|
+
self.lock.lock()
|
|
266
|
+
guard job.stage == .queued else { self.lock.unlock(); return }
|
|
267
|
+
let shouldEncode = !job.cancelled
|
|
268
|
+
job.stage = .worker; self.lock.unlock()
|
|
269
|
+
let result: Result<Data, Error>? = shouldEncode ? Result { try encode() } : nil
|
|
270
|
+
self.lock.lock(); job.stage = .delivery; self.lock.unlock()
|
|
219
271
|
_ = self.main(0) { [weak self] in
|
|
220
|
-
guard let self
|
|
221
|
-
self.
|
|
272
|
+
guard let self else { return }
|
|
273
|
+
self.lock.lock(); job.stage = .settled; self.jobs.removeAll { $0 === job }; self.lock.unlock()
|
|
274
|
+
guard self.generation == expected else { return }
|
|
275
|
+
guard let result else { return }
|
|
222
276
|
switch result {
|
|
223
277
|
case let .success(bytes): deliver(bytes)
|
|
224
278
|
case let .failure(error): self.failure(error)
|
|
@@ -228,6 +282,11 @@ final class NimoCanvasEncoder {
|
|
|
228
282
|
}
|
|
229
283
|
|
|
230
284
|
func invalidate() {
|
|
231
|
-
generation &+= 1
|
|
285
|
+
generation &+= 1
|
|
286
|
+
lock.lock()
|
|
287
|
+
for job in jobs where job.stage == .queued { job.cancelled = true }
|
|
288
|
+
lock.unlock()
|
|
289
|
+
// Do not erase queued producers: their no-op worker and Main delivery
|
|
290
|
+
// fences must actually execute before teardown can report settlement.
|
|
232
291
|
}
|
|
233
292
|
}
|
|
@@ -32,6 +32,7 @@ final class NimoCanvasSession {
|
|
|
32
32
|
private var sentRevision: UInt64 = 0
|
|
33
33
|
private var ticket: UInt64 = 0
|
|
34
34
|
private var flight: Flight?
|
|
35
|
+
var mayHaveEntered: Bool { active || flight != nil }
|
|
35
36
|
|
|
36
37
|
func offer(_ frame: Data, scope: String, force: Bool = false) -> [Action] {
|
|
37
38
|
if self.scope != scope || force { forceRevision &+= 1 }
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if os(macOS) || os(iOS)
|
|
3
|
+
import CryptoKit
|
|
4
|
+
import Darwin
|
|
5
|
+
|
|
6
|
+
struct NimoMacProcessBirth: Codable, Equatable {
|
|
7
|
+
let seconds: UInt64
|
|
8
|
+
let microseconds: UInt64
|
|
9
|
+
|
|
10
|
+
}
|
|
11
|
+
struct NimoMacFixtureConfiguration: Codable {
|
|
12
|
+
let version: Int
|
|
13
|
+
let ownerPid: Int32
|
|
14
|
+
let ownerAuditToken: String
|
|
15
|
+
let ownerBirth: NimoMacProcessBirth
|
|
16
|
+
let planSha256: String
|
|
17
|
+
let grantSha256: String
|
|
18
|
+
let bootstrapNonce: String
|
|
19
|
+
let appExecutableSha256: String
|
|
20
|
+
let appBundleId: String
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Private, one-owner fixture IPC. Only typed lifecycle operations reach Main.
|
|
24
|
+
/// No socket path, packet, host address or connection command is caller supplied.
|
|
25
|
+
final class NimoMacResourceTransport {
|
|
26
|
+
let configuration: NimoMacFixtureConfiguration
|
|
27
|
+
private let process: NimoResourceProcess
|
|
28
|
+
private let directory: String
|
|
29
|
+
private let directoryFd: Int32
|
|
30
|
+
private let directoryDevice: UInt64
|
|
31
|
+
private let directoryInode: UInt64
|
|
32
|
+
private let socketFd: Int32
|
|
33
|
+
private let socketPath: String
|
|
34
|
+
private let processAuditToken: String
|
|
35
|
+
private let executableSha256: String
|
|
36
|
+
private let queue = DispatchQueue(label: "NimoResourceFixtureSocket")
|
|
37
|
+
|
|
38
|
+
init(process: NimoResourceProcess) throws {
|
|
39
|
+
#if os(iOS)
|
|
40
|
+
guard ProcessInfo.processInfo.isiOSAppOnMac else { throw transportError("fixture-requires-ios-on-mac") }
|
|
41
|
+
#endif
|
|
42
|
+
guard let text = ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"], text.utf8.count <= 4096,
|
|
43
|
+
let bytes = text.data(using: .utf8), let object = try JSONSerialization.jsonObject(with: bytes) as? [String: Any],
|
|
44
|
+
Set(object.keys) == Set(["version","ownerPid","ownerAuditToken","ownerBirth","planSha256","grantSha256","bootstrapNonce","appExecutableSha256","appBundleId"]),
|
|
45
|
+
let birth = object["ownerBirth"] as? [String: Any], Set(birth.keys) == Set(["seconds","microseconds"]) else { throw transportError("invalid-bootstrap") }
|
|
46
|
+
configuration = try JSONDecoder().decode(NimoMacFixtureConfiguration.self, from: bytes)
|
|
47
|
+
guard configuration.version == 1, configuration.ownerPid > 1,
|
|
48
|
+
configuration.ownerPid != process.pid,
|
|
49
|
+
UUID(uuidString: configuration.bootstrapNonce)?.uuidString.lowercased() == configuration.bootstrapNonce,
|
|
50
|
+
[configuration.planSha256, configuration.grantSha256, configuration.appExecutableSha256, configuration.ownerAuditToken].allSatisfy({ $0.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil }),
|
|
51
|
+
Bundle.main.bundleIdentifier == configuration.appBundleId,
|
|
52
|
+
let executable = Bundle.main.executableURL else { throw transportError("bootstrap-identity-mismatch") }
|
|
53
|
+
let executableBytes = try Data(contentsOf: executable, options: [.mappedIfSafe])
|
|
54
|
+
guard executableBytes.count <= 256 * 1024 * 1024 else { throw transportError("executable-budget") }
|
|
55
|
+
executableSha256 = SHA256.hash(data: executableBytes).map { String(format: "%02x", $0) }.joined()
|
|
56
|
+
guard executableSha256 == configuration.appExecutableSha256 else { throw transportError("executable-mismatch") }
|
|
57
|
+
self.process = process; processAuditToken = try nimoOwnAuditToken()
|
|
58
|
+
directory = NSHomeDirectory() + "/tmp/nr-" + configuration.bootstrapNonce.replacingOccurrences(of: "-", with: "").prefix(16)
|
|
59
|
+
// Never reuse another process's directory, socket or receipts.
|
|
60
|
+
guard mkdir(directory, 0o700) == 0 else { throw transportError("fixture-directory-exists-or-unavailable") }
|
|
61
|
+
directoryFd = open(directory, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
|
|
62
|
+
guard directoryFd >= 0 else { throw transportError("fixture-directory-open") }
|
|
63
|
+
var info = stat()
|
|
64
|
+
guard fstat(directoryFd, &info) == 0, info.st_uid == geteuid(), info.st_mode & 0o777 == 0o700 else { throw transportError("fixture-directory-permissions") }
|
|
65
|
+
directoryDevice = UInt64(info.st_dev); directoryInode = UInt64(info.st_ino)
|
|
66
|
+
socketPath = directory + "/s"
|
|
67
|
+
socketFd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
68
|
+
guard socketFd >= 0 else { throw transportError("fixture-socket-open") }
|
|
69
|
+
var address = sockaddr_un(); address.sun_family = sa_family_t(AF_UNIX)
|
|
70
|
+
let pathBytes = Array(socketPath.utf8) + [0]
|
|
71
|
+
guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { throw transportError("fixture-socket-path-budget") }
|
|
72
|
+
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: pathBytes) }
|
|
73
|
+
let length = socklen_t(MemoryLayout<sockaddr_un>.size)
|
|
74
|
+
address.sun_len = UInt8(length)
|
|
75
|
+
let result = withUnsafePointer(to: &address) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(socketFd, $0, length) } }
|
|
76
|
+
guard result == 0, chmod(socketPath, 0o600) == 0, listen(socketFd, 4) == 0 else { throw transportError("fixture-socket-bind") }
|
|
77
|
+
let flags = fcntl(socketFd, F_GETFD); _ = fcntl(socketFd, F_SETFD, flags | FD_CLOEXEC)
|
|
78
|
+
let schedulerQoS: DispatchQoS = .unspecified
|
|
79
|
+
let schedulerFlags = DispatchWorkItemFlags(rawValue: 0)
|
|
80
|
+
enqueueServe(qos: schedulerQoS, flags: schedulerFlags)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A narrow scheduling boundary for this transport's original accept loop.
|
|
84
|
+
@inline(never)
|
|
85
|
+
private func enqueueServe(qos: DispatchQoS, flags: DispatchWorkItemFlags) {
|
|
86
|
+
queue.async(group: nil, qos: qos, flags: flags) { [weak self] in self?.serve() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func sameDirectory() -> Bool {
|
|
90
|
+
var byPath = stat(), byFd = stat()
|
|
91
|
+
return lstat(directory, &byPath) == 0 && fstat(directoryFd, &byFd) == 0 &&
|
|
92
|
+
byPath.st_mode & S_IFMT == S_IFDIR && byPath.st_dev == byFd.st_dev && byPath.st_ino == byFd.st_ino &&
|
|
93
|
+
UInt64(byFd.st_dev) == directoryDevice && UInt64(byFd.st_ino) == directoryInode && byFd.st_uid == geteuid() && byFd.st_mode & 0o777 == 0o700
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func persist(_ bytes: Data, nonce: String, kind: String) throws {
|
|
97
|
+
guard sameDirectory(), UUID(uuidString: nonce)?.uuidString.lowercased() == nonce,
|
|
98
|
+
["intent","receipt","admission","transport"].contains(kind), bytes.count <= 32_768 else { throw transportError("journal-identity-mismatch") }
|
|
99
|
+
let final = "\(nonce).\(kind).json", temp = "\(nonce).\(kind).pending"
|
|
100
|
+
let fd = openat(directoryFd, temp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600)
|
|
101
|
+
guard fd >= 0 else { throw transportError("journal-intent-exists-or-unavailable") }
|
|
102
|
+
defer { close(fd); unlinkat(directoryFd, temp, 0) }
|
|
103
|
+
try bytes.withUnsafeBytes { raw in
|
|
104
|
+
var offset = 0
|
|
105
|
+
while offset < raw.count {
|
|
106
|
+
let count = Darwin.write(fd, raw.baseAddress!.advanced(by: offset), raw.count - offset)
|
|
107
|
+
guard count > 0 else { throw transportError("journal-write-failed") }; offset += count
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
guard fsync(fd) == 0, sameDirectory(), linkat(directoryFd, temp, directoryFd, final, 0) == 0,
|
|
111
|
+
fsync(directoryFd) == 0 else { throw transportError("journal-atomic-publish-failed") }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func peerAllowed(_ fd: Int32) -> Bool {
|
|
115
|
+
var peerPid: Int32 = 0, length = socklen_t(MemoryLayout<Int32>.size)
|
|
116
|
+
var uid: uid_t = 0, gid: gid_t = 0
|
|
117
|
+
var token = audit_token_t(), tokenLength = socklen_t(MemoryLayout<audit_token_t>.size)
|
|
118
|
+
return getsockopt(fd, SOL_LOCAL, LOCAL_PEERPID, &peerPid, &length) == 0 &&
|
|
119
|
+
length == MemoryLayout<Int32>.size && peerPid == configuration.ownerPid && getpeereid(fd, &uid, &gid) == 0 && uid == geteuid() &&
|
|
120
|
+
getsockopt(fd, SOL_LOCAL, LOCAL_PEERTOKEN, &token, &tokenLength) == 0 &&
|
|
121
|
+
tokenLength == MemoryLayout<audit_token_t>.size && nimoAuditTokenHex(token) == configuration.ownerAuditToken && sameDirectory()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private func serve() {
|
|
125
|
+
while true {
|
|
126
|
+
let fd = accept(socketFd, nil, nil)
|
|
127
|
+
if fd < 0 { if errno == EINTR { continue }; return }
|
|
128
|
+
defer { close(fd) }
|
|
129
|
+
guard peerAllowed(fd) else { continue }
|
|
130
|
+
var timeout = timeval(tv_sec: 5, tv_usec: 0)
|
|
131
|
+
_ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
|
132
|
+
_ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
|
133
|
+
var noSignal: Int32 = 1
|
|
134
|
+
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
|
135
|
+
var request = Data(), complete = false
|
|
136
|
+
let readDeadline = NimoResourceClock.now() + 5_000
|
|
137
|
+
while request.count <= 16_384, NimoResourceClock.now() < readDeadline {
|
|
138
|
+
var buffer = [UInt8](repeating: 0, count: 1024)
|
|
139
|
+
let count = read(fd, &buffer, buffer.count)
|
|
140
|
+
if count <= 0 { break }
|
|
141
|
+
request.append(contentsOf: buffer.prefix(count))
|
|
142
|
+
if request.contains(10) { complete = true; break }
|
|
143
|
+
}
|
|
144
|
+
guard complete, request.count <= 16_384, request.last == 10, request.dropLast().contains(10) == false,
|
|
145
|
+
peerAllowed(fd) else { continue }
|
|
146
|
+
do {
|
|
147
|
+
guard let envelope = try JSONSerialization.jsonObject(with: request) as? [String: Any],
|
|
148
|
+
envelope["bootstrapNonce"] as? String == configuration.bootstrapNonce else { throw transportError("bootstrap-capability-mismatch") }
|
|
149
|
+
let response: Data
|
|
150
|
+
if Set(envelope.keys) == Set(["bootstrapNonce","hello"]), envelope["hello"] as? Bool == true {
|
|
151
|
+
response = try JSONSerialization.data(withJSONObject: hello(), options: [.sortedKeys])
|
|
152
|
+
} else {
|
|
153
|
+
guard Set(envelope.keys) == Set(["bootstrapNonce","request"]), let value = envelope["request"] as? [String: Any] else { throw transportError("invalid-envelope") }
|
|
154
|
+
let canonical = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
155
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
156
|
+
// Connection execution is serial; parent timeout does not cancel
|
|
157
|
+
// native cleanup or renew its original operation authority.
|
|
158
|
+
final class Box: @unchecked Sendable { var result: Result<Data, Error>? }
|
|
159
|
+
let box = Box()
|
|
160
|
+
Task { @MainActor in
|
|
161
|
+
do { box.result = .success(try await NimoResourceFixture.shared.execute(canonical)) }
|
|
162
|
+
catch { box.result = .failure(error) }
|
|
163
|
+
semaphore.signal()
|
|
164
|
+
}
|
|
165
|
+
semaphore.wait()
|
|
166
|
+
guard peerAllowed(fd), let result = box.result else { continue }
|
|
167
|
+
let receipt = try result.get()
|
|
168
|
+
response = try JSONSerialization.data(withJSONObject: ["receipt": try JSONSerialization.jsonObject(with: receipt)], options: [.sortedKeys])
|
|
169
|
+
}
|
|
170
|
+
send(response + Data([10]), fd)
|
|
171
|
+
} catch {
|
|
172
|
+
let code = String(error.localizedDescription.prefix(160))
|
|
173
|
+
if let bytes = try? JSONSerialization.data(withJSONObject: ["error": code], options: [.sortedKeys]) { send(bytes + Data([10]), fd) }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
private func send(_ data: Data, _ fd: Int32) {
|
|
178
|
+
data.withUnsafeBytes { bytes in
|
|
179
|
+
var offset = 0
|
|
180
|
+
while offset < bytes.count {
|
|
181
|
+
let count = Darwin.write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
|
|
182
|
+
if count <= 0 { return }; offset += count
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
private func hello() -> [String: Any] {
|
|
187
|
+
["protocol": "nimo-macos-resource-v1", "process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
188
|
+
"processAuditToken": processAuditToken, "ownerAuditToken": configuration.ownerAuditToken,
|
|
189
|
+
"ownerPid": configuration.ownerPid, "ownerBirth": ["seconds": configuration.ownerBirth.seconds, "microseconds": configuration.ownerBirth.microseconds],
|
|
190
|
+
"executableSha256": executableSha256, "bundleIdentifier": configuration.appBundleId,
|
|
191
|
+
"directory": ["device": directoryDevice, "inode": directoryInode], "socketPath": socketPath]
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Audit tokens are opaque kernel identities: never interpret their word layout.
|
|
195
|
+
private func nimoAuditTokenHex(_ token: audit_token_t) -> String {
|
|
196
|
+
withUnsafeBytes(of: token) { $0.map { String(format: "%02x", $0) }.joined() }
|
|
197
|
+
}
|
|
198
|
+
private func nimoOwnAuditToken() throws -> String {
|
|
199
|
+
var token = audit_token_t()
|
|
200
|
+
var count = mach_msg_type_number_t(MemoryLayout<audit_token_t>.size / MemoryLayout<integer_t>.size)
|
|
201
|
+
let result = withUnsafeMutablePointer(to: &token) { pointer in
|
|
202
|
+
pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
|
|
203
|
+
task_info(mach_task_self_, task_flavor_t(TASK_AUDIT_TOKEN), $0, &count)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
guard result == KERN_SUCCESS, count == MemoryLayout<audit_token_t>.size / MemoryLayout<integer_t>.size else { throw transportError("process-audit-token-unavailable") }
|
|
207
|
+
return nimoAuditTokenHex(token)
|
|
208
|
+
}
|
|
209
|
+
private func transportError(_ code: String) -> Error { NSError(domain: "NimoResourceTransport", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
|
|
210
|
+
#else
|
|
211
|
+
struct NimoMacFixtureConfiguration { let planSha256 = "", grantSha256 = "" }
|
|
212
|
+
final class NimoMacResourceTransport {
|
|
213
|
+
let configuration = NimoMacFixtureConfiguration()
|
|
214
|
+
init(process: NimoResourceProcess) throws { throw NSError(domain: "NimoResourceFixture", code: 1) }
|
|
215
|
+
func persist(_ bytes: Data, nonce: String, kind: String) throws { throw NSError(domain: "NimoResourceFixture", code: 1) }
|
|
216
|
+
}
|
|
217
|
+
#endif
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One process-local authority shared by the typed Expo bridge and the Mac
|
|
4
|
+
/// fixture socket. Neither accepts packets, connection addresses or file paths.
|
|
5
|
+
@MainActor
|
|
6
|
+
public enum NimoResourceFixtureBridge {
|
|
7
|
+
public static func execute(_ json: String) async throws -> String {
|
|
8
|
+
let bytes = Data(json.utf8)
|
|
9
|
+
return String(decoding: try await NimoResourceFixture.shared.execute(bytes), as: UTF8.self)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@MainActor
|
|
14
|
+
final class NimoResourceFixture {
|
|
15
|
+
static let shared = NimoResourceFixture()
|
|
16
|
+
let process = NimoResourceProcess(pid: ProcessInfo.processInfo.processIdentifier, instanceId: UUID().uuidString.lowercased())
|
|
17
|
+
private(set) var session: NimoResourceSession?
|
|
18
|
+
private(set) var owner: Nimo?
|
|
19
|
+
private var completed: [String: (Data, Data)] = [:]
|
|
20
|
+
private var pending: [String: (Data, Task<Data, Error>)] = [:]
|
|
21
|
+
private var transport: NimoMacResourceTransport?
|
|
22
|
+
private var startupError: Error?
|
|
23
|
+
private var originalClosed = false
|
|
24
|
+
private let managerEntries = NimoResourceManagerEntries()
|
|
25
|
+
var pendingManagerEntries: Int { managerEntries.all }
|
|
26
|
+
var pendingOriginalManagerEntries: Int { managerEntries.originalNimo }
|
|
27
|
+
func beginManagerEntry(_ wearable: String) -> NimoResourceManagerEntries.Ticket { managerEntries.begin(wearable) }
|
|
28
|
+
func endManagerEntry(_ ticket: NimoResourceManagerEntries.Ticket) { managerEntries.end(ticket) }
|
|
29
|
+
#if os(macOS)
|
|
30
|
+
private let requested = ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"] != nil
|
|
31
|
+
#elseif os(iOS)
|
|
32
|
+
private let requested = ProcessInfo.processInfo.isiOSAppOnMac && ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"] != nil
|
|
33
|
+
#else
|
|
34
|
+
private let requested = false
|
|
35
|
+
#endif
|
|
36
|
+
var enabled: Bool { requested }
|
|
37
|
+
|
|
38
|
+
func start() {
|
|
39
|
+
guard enabled, transport == nil, startupError == nil else { return }
|
|
40
|
+
do { transport = try NimoMacResourceTransport(process: process) }
|
|
41
|
+
catch { startupError = error }
|
|
42
|
+
}
|
|
43
|
+
func allowsManager(_ wearable: String, name: String? = nil) -> Bool {
|
|
44
|
+
guard enabled else { return true }
|
|
45
|
+
// A different manager can clean up the captured Nimo object. Permit it
|
|
46
|
+
// only after the original resource has actually completed teardown.
|
|
47
|
+
guard wearable.contains("NIMO") else { return NimoResourceManagerState.allowsReplacement(fixtureEnabled: enabled, originalClosed: originalClosed && session?.errors.isEmpty == true && session?.connected == false) }
|
|
48
|
+
guard let session, !session.cancelled, session.releaseNonce == nil, session.original == nil,
|
|
49
|
+
name == nil || name == session.pair.name else { return false }
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
func hasVerifiedOriginalClosure(_ candidate: Nimo) -> Bool {
|
|
53
|
+
originalClosed && owner === candidate && session?.isOwner(candidate) == true
|
|
54
|
+
&& session?.errors.isEmpty == true && session?.connected == false
|
|
55
|
+
}
|
|
56
|
+
func admit(_ candidate: Nimo, name: String?) -> Bool {
|
|
57
|
+
guard enabled else { return true }
|
|
58
|
+
guard (DeviceManager.shared.sgc as AnyObject?) === candidate, let session, let transport else { return false }
|
|
59
|
+
let admitted = session.admit(candidate, instanceId: candidate.resourceInstanceId, name: name) { proposed in
|
|
60
|
+
self.persistAdmission(session, instanceId: candidate.resourceInstanceId, elapsed: proposed, transportStart: false, transport: transport)
|
|
61
|
+
}
|
|
62
|
+
if session.isOwner(candidate), owner == nil { owner = candidate }
|
|
63
|
+
return admitted && owner === candidate
|
|
64
|
+
}
|
|
65
|
+
private func persistAdmission(_ session: NimoResourceSession, instanceId: String, elapsed: UInt64, transportStart: Bool, transport: NimoMacResourceTransport) -> Bool {
|
|
66
|
+
let value: [String: Any] = ["protocol": "nimo-macos-resource-v1", "captureNonce": session.captureNonce,
|
|
67
|
+
"process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
68
|
+
"planSha256": transport.configuration.planSha256, "grantSha256": transport.configuration.grantSha256,
|
|
69
|
+
"instanceId": instanceId, "peripheralUuid": session.pair.peripheralUuid, "sessionGeneration": transportStart ? 1 : 0,
|
|
70
|
+
"armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": transportStart ? nullable(session.admittedElapsedMs) : elapsed,
|
|
71
|
+
"startedElapsedMs": transportStart ? elapsed : NSNull() as Any]
|
|
72
|
+
do {
|
|
73
|
+
let bytes = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
74
|
+
try transport.persist(bytes, nonce: session.captureNonce, kind: transportStart ? "transport" : "admission")
|
|
75
|
+
return true
|
|
76
|
+
} catch { return false }
|
|
77
|
+
}
|
|
78
|
+
func preserveScan(_ name: String) -> Bool {
|
|
79
|
+
guard enabled, let owner, let session, session.isOwner(owner),
|
|
80
|
+
session.original == nil, !session.cancelled, name == session.pair.name else { return false }
|
|
81
|
+
return (DeviceManager.shared.sgc as AnyObject?) === owner
|
|
82
|
+
}
|
|
83
|
+
func beginTransport(_ candidate: Nimo, peripheral: AnyObject, uuid: String) -> Bool {
|
|
84
|
+
guard enabled else { return true }
|
|
85
|
+
guard (DeviceManager.shared.sgc as AnyObject?) === candidate, let session, let transport else { return false }
|
|
86
|
+
return session.beginTransport(candidate, peripheral: peripheral, uuid: uuid) { proposed in
|
|
87
|
+
self.persistAdmission(session, instanceId: candidate.resourceInstanceId, elapsed: proposed, transportStart: true, transport: transport)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
func allowsProducer(_ candidate: Nimo) -> Bool {
|
|
91
|
+
guard enabled else { return true }
|
|
92
|
+
return session?.isOwner(candidate) == true && session?.releaseNonce == nil && session?.cancelled == false && (DeviceManager.shared.sgc as AnyObject?) === candidate
|
|
93
|
+
}
|
|
94
|
+
func captured(_ candidate: Nimo) -> NimoResourceSession? {
|
|
95
|
+
guard enabled, session?.isOwner(candidate) == true else { return nil }; return session
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func execute(_ bytes: Data) async throws -> Data {
|
|
99
|
+
start()
|
|
100
|
+
guard bytes.count <= 16_384, enabled, let transport, startupError == nil else { throw failure("fixture-unavailable") }
|
|
101
|
+
let request = try decode(bytes)
|
|
102
|
+
guard request.version == 1, request.expectedProcess == process,
|
|
103
|
+
request.planSha256 == transport.configuration.planSha256,
|
|
104
|
+
request.grantSha256 == transport.configuration.grantSha256 else { throw failure("fixture-authority-mismatch") }
|
|
105
|
+
if let (original, receipt) = completed[request.requestNonce] {
|
|
106
|
+
guard original == bytes else { throw failure("request-nonce-reused") }; return receipt
|
|
107
|
+
}
|
|
108
|
+
if let (original, task) = pending[request.requestNonce] {
|
|
109
|
+
guard original == bytes else { throw failure("request-nonce-reused") }; return try await task.value
|
|
110
|
+
}
|
|
111
|
+
if request.operation != .arm {
|
|
112
|
+
guard let session, session.captureNonce == request.captureNonce, session.pair == request.expectedPair else { throw failure("capture-mismatch") }
|
|
113
|
+
let observed = session.original ?? session.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session.pair.peripheralUuid, sessionGeneration: 0) }
|
|
114
|
+
if let expected = request.original { guard expected == observed else { throw failure("original-session-mismatch") } }
|
|
115
|
+
if request.operation == .release, session.hasOwner, request.original == nil { throw failure("release-requires-original") }
|
|
116
|
+
if request.operation == .release, let release = session.releaseNonce, release != request.requestNonce { throw failure("original-release-nonce-required") }
|
|
117
|
+
}
|
|
118
|
+
let recovery = request.operation == .release || request.operation == .cancel || session?.releaseNonce != nil
|
|
119
|
+
guard completed.count + pending.count < (recovery ? 256 : 192) else { throw failure("request-budget-exhausted") }
|
|
120
|
+
// Intent is persisted before ANY capture or release mutation. No caller
|
|
121
|
+
// path is used; replay compares the exact original request bytes.
|
|
122
|
+
try transport.persist(bytes, nonce: request.requestNonce, kind: "intent")
|
|
123
|
+
let received = NimoResourceClock.now()
|
|
124
|
+
let task = Task { @MainActor in
|
|
125
|
+
let result = try await self.perform(request, received: received)
|
|
126
|
+
let encoded = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
|
|
127
|
+
guard encoded.count <= 32_768 else { throw self.failure("receipt-budget-exhausted") }
|
|
128
|
+
try transport.persist(encoded, nonce: request.requestNonce, kind: "receipt")
|
|
129
|
+
return encoded
|
|
130
|
+
}
|
|
131
|
+
pending[request.requestNonce] = (bytes, task)
|
|
132
|
+
do {
|
|
133
|
+
let receipt = try await task.value
|
|
134
|
+
completed[request.requestNonce] = (bytes, receipt); pending.removeValue(forKey: request.requestNonce)
|
|
135
|
+
return receipt
|
|
136
|
+
} catch {
|
|
137
|
+
// Keep the original failed intent fenced; do not reexecute effects.
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func perform(_ request: NimoResourceRequest, received: UInt64) async throws -> [String: Any] {
|
|
143
|
+
if request.operation == .arm {
|
|
144
|
+
guard session == nil, request.original == nil, DeviceManager.shared.sgc == nil, pendingManagerEntries == 0 else { throw failure("arm-requires-unowned-manager") }
|
|
145
|
+
session = NimoResourceSession(captureNonce: request.captureNonce, pair: request.expectedPair)
|
|
146
|
+
}
|
|
147
|
+
guard let session, session.captureNonce == request.captureNonce, session.pair == request.expectedPair else { throw failure("capture-mismatch") }
|
|
148
|
+
if let expected = request.original {
|
|
149
|
+
let observed = session.original ?? session.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session.pair.peripheralUuid, sessionGeneration: 0) }
|
|
150
|
+
guard expected == observed else { throw failure("original-session-mismatch") }
|
|
151
|
+
} else if request.operation == .release, session.hasOwner { throw failure("release-requires-original") }
|
|
152
|
+
if request.operation == .release || request.operation == .cancel {
|
|
153
|
+
if session.releaseNonce == nil {
|
|
154
|
+
let mic = owner?.resourceMicStopRequired ?? false, canvas = owner?.resourceCanvasRequired ?? false
|
|
155
|
+
guard session.beginRelease(nonce: request.requestNonce, cancel: request.operation == .cancel, mic: mic, canvas: canvas) else { throw failure("release-mismatch") }
|
|
156
|
+
if let owner { await owner.releaseResource(session) }
|
|
157
|
+
else {
|
|
158
|
+
let deadline = NimoResourceClock.now() + 1_000
|
|
159
|
+
while pendingOriginalManagerEntries != 0, NimoResourceClock.now() < deadline { try? await Task.sleep(nanoseconds: 10_000_000) }
|
|
160
|
+
if pendingOriginalManagerEntries != 0 { session.recordError("pending-manager-entry-not-settled") }
|
|
161
|
+
session.proveNoTransport()
|
|
162
|
+
}
|
|
163
|
+
} else if request.operation == .release, session.releaseNonce != request.requestNonce {
|
|
164
|
+
throw failure("original-release-nonce-required")
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
var producers = NimoResourceProducers()
|
|
168
|
+
producers.postedCallbacks = pendingOriginalManagerEntries
|
|
169
|
+
if let owner { producers = await owner.resourceProducers() }
|
|
170
|
+
originalClosed = session.settled(producers: producers)
|
|
171
|
+
let manager = DeviceManager.shared.sgc
|
|
172
|
+
let managerId = (manager as? Nimo)?.resourceInstanceId
|
|
173
|
+
let current: [String: Any] = ["type": nullable(manager?.type), "instanceId": nullable(managerId)]
|
|
174
|
+
return ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
|
|
175
|
+
"requestNonce": request.requestNonce, "captureNonce": request.captureNonce,
|
|
176
|
+
"planSha256": request.planSha256, "grantSha256": request.grantSha256,
|
|
177
|
+
"process": ["pid": process.pid, "instanceId": process.instanceId],
|
|
178
|
+
"clock": ["kind": "darwin-continuous-time-v1", "receivedElapsedMs": received, "observedElapsedMs": NimoResourceClock.now()],
|
|
179
|
+
"capture": ["armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": nullable(session.admittedElapsedMs), "cancelled": session.cancelled,
|
|
180
|
+
"expectedPair": ["name": session.pair.name, "peripheralUuid": session.pair.peripheralUuid]],
|
|
181
|
+
"currentManager": current, "session": nullable(session.sessionDictionary()),
|
|
182
|
+
"release": session.releaseDictionary(producers: producers), "producers": producers.dictionary, "errors": session.errors]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private func decode(_ bytes: Data) throws -> NimoResourceRequest { try NimoResourceRequest.decode(bytes) }
|
|
186
|
+
private func failure(_ code: String) -> Error { NSError(domain: "NimoResourceFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
|
|
187
|
+
}
|