@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.
@@ -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
- run(session.response(key: key, payload: payload))
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?.flight === current { current.finalStarted = true }
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, self.generation == expected, self.current === item else { return }
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 cancelPending: (() -> Void)?
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
- cancelPending = worker(0) { [weak self] in
217
- let result = Result { try encode() }
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, self.generation == expected else { return }
221
- self.cancelPending = nil
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; cancelPending?(); cancelPending = nil
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
+ }