@craft-native/ios 0.0.351 → 0.0.353

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.351",
3
+ "version": "0.0.353",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6011,6 +6011,8 @@ struct CraftWebView: UIViewRepresentable {
6011
6011
  stopScan: function() { return craft._invoke('bluetoothChannel', {method: 'stopScan'}); },
6012
6012
  connect: function(options) { return craft._invoke('bluetoothChannel', {method: 'connect', options: options || {}}); },
6013
6013
  exchange: function(options) { return craft._invoke('bluetoothChannel', {method: 'exchange', options: options || {}}); },
6014
+ read: function(options) { return craft._invoke('bluetoothChannel', {method: 'read', options: options || {}}); },
6015
+ write: function(options) { return craft._invoke('bluetoothChannel', {method: 'write', options: options || {}}); },
6014
6016
  disconnect: function() { return craft._invoke('bluetoothChannel', {method: 'disconnect'}); }
6015
6017
  };
6016
6018
  craft.heartRate = {
@@ -30,6 +30,13 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
30
30
  private var exchangeTimer: DispatchWorkItem?
31
31
  private var responsePrefix = Data()
32
32
  private var connected = false
33
+ private var streaming = false
34
+ private var notifications: [Data] = []
35
+ private var notificationBytes = 0
36
+ private var readCompletion: Completion?
37
+ private var readTimer: DispatchWorkItem?
38
+ private var writeCompletion: Completion?
39
+ private var writeTimer: DispatchWorkItem?
33
40
 
34
41
  func perform(method: String, args: [Any], completion: @escaping Completion) {
35
42
  if !Thread.isMainThread {
@@ -39,11 +46,13 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
39
46
  let options = args.first as? [String: Any] ?? [:]
40
47
  switch method {
41
48
  case "getState":
42
- completion(.success(["available": true, "connected": connected, "scanning": scanCompletion != nil]))
49
+ completion(.success(["available": true, "connected": connected, "scanning": scanCompletion != nil, "streaming": true]))
43
50
  case "scan": scan(options, completion)
44
51
  case "stopScan": stopScan(); completion(.success(true))
45
52
  case "connect": connect(options, completion)
46
53
  case "exchange": exchange(options, completion)
54
+ case "write": write(options, completion)
55
+ case "read": read(options, completion)
47
56
  case "disconnect": disconnect(); completion(.success(true))
48
57
  default: reject(completion, "Unsupported Bluetooth method", "UNKNOWN_ACTION")
49
58
  }
@@ -102,6 +111,8 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
102
111
  central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
103
112
  let scan = scanCompletion; scanCompletion = nil; scan?(.failure(failure))
104
113
  settleConnect(.failure(failure)); settleExchange(.failure(failure))
114
+ settleRead(.failure(failure)); settleWrite(.failure(failure))
115
+ notifications.removeAll(); notificationBytes = 0
105
116
  connected = false
106
117
  }
107
118
 
@@ -131,6 +142,7 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
131
142
  guard central?.state == .poweredOn else { return reject(completion, "Bluetooth is off", "BLUETOOTH_OFF") }
132
143
  disconnect()
133
144
  peripheral = found; serviceUUID = CBUUID(string: service); writeUUID = CBUUID(string: write); notifyUUID = CBUUID(string: notify)
145
+ streaming = options["stream"] as? Bool == true
134
146
  found.delegate = self; connectCompletion = completion
135
147
  central?.connect(found, options: nil)
136
148
  let timer = DispatchWorkItem { [weak self] in
@@ -161,6 +173,9 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
161
173
  func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
162
174
  guard peripheral === self.peripheral else { return }
163
175
  connected = false; writer = nil; notifier = nil
176
+ settleRead(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
177
+ settleWrite(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
178
+ notifications.removeAll(); notificationBytes = 0
164
179
  settleConnect(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
165
180
  settleExchange(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
166
181
  }
@@ -190,11 +205,11 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
190
205
  }
191
206
  connected = true
192
207
  let id = peripheral.identifier.uuidString
193
- settleConnect(.success(["id": id, "name": names[id] ?? peripheral.name ?? ""]))
208
+ settleConnect(.success(["id": id, "name": names[id] ?? peripheral.name ?? "", "maxWrite": min(182, peripheral.maximumWriteValueLength(for: writer?.properties.contains(.write) == true ? .withResponse : .withoutResponse))]))
194
209
  }
195
210
 
196
211
  private func exchange(_ options: [String: Any], _ completion: @escaping Completion) {
197
- guard exchangeCompletion == nil else { return reject(completion, "A Bluetooth request is already running", "BUSY") }
212
+ guard !streaming, exchangeCompletion == nil else { return reject(completion, "A Bluetooth request is already running or streaming", "BUSY") }
198
213
  guard connected, let peripheral, let writer else { return reject(completion, "Bluetooth device is disconnected", "DISCONNECTED") }
199
214
  guard let bytes = options["bytes"] as? [Int], !bytes.isEmpty, bytes.count <= 182, bytes.allSatisfy({ (0...255).contains($0) }),
200
215
  let prefix = options["responsePrefix"] as? [Int], !prefix.isEmpty, prefix.count <= 16, prefix.allSatisfy({ (0...255).contains($0) }) else {
@@ -217,12 +232,28 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
217
232
  }
218
233
 
219
234
  func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
220
- guard peripheral === self.peripheral, characteristic === writer, error != nil else { return }
221
- settleExchange(.failure(CraftBluetoothError(message: "Bluetooth write failed", code: "WRITE_FAILED")))
235
+ guard peripheral === self.peripheral, characteristic === writer else { return }
236
+ if let error {
237
+ let failure = CraftBluetoothError(message: error.localizedDescription, code: "WRITE_FAILED")
238
+ settleWrite(.failure(failure)); settleExchange(.failure(failure))
239
+ } else { settleWrite(.success(true)) }
222
240
  }
223
241
 
224
242
  func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
225
- guard peripheral === self.peripheral, characteristic === notifier, exchangeCompletion != nil else { return }
243
+ guard peripheral === self.peripheral, characteristic === notifier else { return }
244
+ if streaming {
245
+ guard error == nil, let data = characteristic.value, !data.isEmpty, data.count <= 4096 else {
246
+ disconnect(); return
247
+ }
248
+ if readCompletion != nil { settleRead(.success(data.map(Int.init))); return }
249
+ guard notifications.count < 512, notificationBytes + data.count <= 1048576 else {
250
+ settleRead(.failure(CraftBluetoothError(message: "Bluetooth receive queue overflow", code: "OVERFLOW")))
251
+ disconnect(); return
252
+ }
253
+ notifications.append(data); notificationBytes += data.count
254
+ return
255
+ }
256
+ guard exchangeCompletion != nil else { return }
226
257
  guard error == nil, let data = characteristic.value else {
227
258
  settleExchange(.failure(CraftBluetoothError(message: "Bluetooth notification failed", code: "READ_FAILED"))); return
228
259
  }
@@ -233,8 +264,51 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
233
264
  settleExchange(.success(data.map(Int.init)))
234
265
  }
235
266
 
267
+ private func settleRead(_ result: Result<Any, CraftBluetoothError>) {
268
+ readTimer?.cancel(); readTimer = nil
269
+ let completion = readCompletion; readCompletion = nil; completion?(result)
270
+ }
271
+ private func settleWrite(_ result: Result<Any, CraftBluetoothError>) {
272
+ writeTimer?.cancel(); writeTimer = nil
273
+ let completion = writeCompletion; writeCompletion = nil; completion?(result)
274
+ }
275
+ private func read(_ options: [String: Any], _ completion: @escaping Completion) {
276
+ guard streaming, connected else { return reject(completion, "Bluetooth stream disconnected", "DISCONNECTED") }
277
+ guard readCompletion == nil else { return reject(completion, "A Bluetooth read is running", "BUSY") }
278
+ if !notifications.isEmpty {
279
+ let data = notifications.removeFirst(); notificationBytes -= data.count
280
+ completion(.success(data.map(Int.init))); return
281
+ }
282
+ readCompletion = completion
283
+ let timeout = min(20, max(0.001, (options["timeoutMs"] as? Double ?? 10000) / 1000))
284
+ let timer = DispatchWorkItem { [weak self] in self?.settleRead(.failure(CraftBluetoothError(message: "Bluetooth read timed out", code: "TIMEOUT"))) }
285
+ readTimer = timer
286
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timer)
287
+ }
288
+ private func write(_ options: [String: Any], _ completion: @escaping Completion) {
289
+ guard streaming, connected, let peripheral, let writer else { return reject(completion, "Bluetooth stream disconnected", "DISCONNECTED") }
290
+ guard writeCompletion == nil else { return reject(completion, "A Bluetooth write is running", "BUSY") }
291
+ guard let bytes = options["bytes"] as? [Int], !bytes.isEmpty, bytes.count <= 182, bytes.allSatisfy({ (0...255).contains($0) }) else { return reject(completion, "Invalid Bluetooth bytes", "INVALID_ARGUMENT") }
292
+ let type: CBCharacteristicWriteType = writer.properties.contains(.write) ? .withResponse : .withoutResponse
293
+ guard bytes.count <= peripheral.maximumWriteValueLength(for: type) else { return reject(completion, "Bluetooth write exceeds MTU", "MTU_TOO_SMALL") }
294
+ if type == .withoutResponse, !peripheral.canSendWriteWithoutResponse { return reject(completion, "Bluetooth queue busy", "BUSY") }
295
+ if type == .withResponse {
296
+ writeCompletion = completion
297
+ let timer = DispatchWorkItem { [weak self] in
298
+ self?.settleWrite(.failure(CraftBluetoothError(message: "Bluetooth write timed out", code: "TIMEOUT")))
299
+ self?.disconnect()
300
+ }
301
+ writeTimer = timer
302
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: timer)
303
+ }
304
+ peripheral.writeValue(Data(bytes.map(UInt8.init)), for: writer, type: type)
305
+ if type == .withoutResponse { completion(.success(true)) }
306
+ }
236
307
  private func disconnect() {
237
308
  stopScan()
309
+ settleRead(.failure(CraftBluetoothError(message: "Bluetooth stream closed", code: "DISCONNECTED")))
310
+ settleWrite(.failure(CraftBluetoothError(message: "Bluetooth stream closed", code: "DISCONNECTED")))
311
+ notifications.removeAll(); notificationBytes = 0; streaming = false
238
312
  settleConnect(.failure(CraftBluetoothError(message: "Bluetooth connection cancelled", code: "CANCELLED")))
239
313
  settleExchange(.failure(CraftBluetoothError(message: "Bluetooth request cancelled", code: "CANCELLED")))
240
314
  if let peripheral { central?.cancelPeripheralConnection(peripheral); peripheral.delegate = nil }