craft-native 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.
@@ -33,6 +33,13 @@ internal class CraftBluetooth(private val context: Context) {
33
33
  private var writeId: UUID? = null
34
34
  private var notifyId: UUID? = null
35
35
  private var connected = false
36
+ private var streaming = false
37
+ private val notifications = java.util.ArrayDeque<ByteArray>()
38
+ private var notificationBytes = 0
39
+ private var readDone: ((CraftNativeCapabilityResult) -> Unit)? = null
40
+ private var writeDone: ((CraftNativeCapabilityResult) -> Unit)? = null
41
+ private var readTimer: Runnable? = null
42
+ private var writeTimer: Runnable? = null
36
43
  private var mtu = 23
37
44
  private var connectDone: ((CraftNativeCapabilityResult) -> Unit)? = null
38
45
  private var exchangeDone: ((CraftNativeCapabilityResult) -> Unit)? = null
@@ -50,11 +57,13 @@ internal class CraftBluetooth(private val context: Context) {
50
57
  val options = args.optJSONObject(0) ?: JSONObject()
51
58
  try {
52
59
  when (method) {
53
- "getState" -> done(CraftNativeCapabilityResult(JSONObject().put("available", adapter != null).put("connected", connected).put("scanning", scanDone != null)))
60
+ "getState" -> done(CraftNativeCapabilityResult(JSONObject().put("available", adapter != null).put("connected", connected).put("scanning", scanDone != null).put("streaming", true)))
54
61
  "scan" -> scan(options, done)
55
62
  "stopScan" -> { stopScan(failure("CANCELLED", "Bluetooth scan cancelled")); done(CraftNativeCapabilityResult(true)) }
56
63
  "connect" -> connect(options, done)
57
64
  "exchange" -> exchange(options, done)
65
+ "read" -> read(options, done)
66
+ "write" -> write(options, done)
58
67
  "disconnect" -> { disconnect(); done(CraftNativeCapabilityResult(true)) }
59
68
  else -> done(failure("UNKNOWN_ACTION", "Unsupported Bluetooth method"))
60
69
  }
@@ -124,6 +133,7 @@ internal class CraftBluetooth(private val context: Context) {
124
133
  val device = devices[options.optString("id")] ?: throw IllegalArgumentException()
125
134
  disconnect()
126
135
  serviceId = UUID.fromString(options.getString("service")); writeId = UUID.fromString(options.getString("write")); notifyId = UUID.fromString(options.getString("notify"))
136
+ streaming = options.optBoolean("stream")
127
137
  connectDone = done
128
138
  gatt = device.connectGatt(context, false, callbacks, BluetoothDevice.TRANSPORT_LE)
129
139
  connectTimer = timer(15000) { failConnection("TIMEOUT", "Device did not connect; wake it and try again") }
@@ -141,7 +151,7 @@ internal class CraftBluetooth(private val context: Context) {
141
151
  } }
142
152
  override fun onMtuChanged(peer: BluetoothGatt, value: Int, status: Int) { handler.post {
143
153
  if (peer !== gatt) return@post
144
- if (status != BluetoothGatt.GATT_SUCCESS || value < 185) { failConnection("MTU_TOO_SMALL", "Bluetooth MTU is too small for sensor history"); return@post }
154
+ if (status != BluetoothGatt.GATT_SUCCESS || value < (if (streaming) 23 else 185)) { failConnection("MTU_TOO_SMALL", "Bluetooth MTU is too small for sensor history"); return@post }
145
155
  mtu = value
146
156
  if (!peer.discoverServices()) failConnection("CONNECT_FAILED", "Could not discover Bluetooth services")
147
157
  } }
@@ -160,15 +170,27 @@ internal class CraftBluetooth(private val context: Context) {
160
170
  if (peer !== gatt || descriptor.characteristic !== notifier) return@post
161
171
  if (status != BluetoothGatt.GATT_SUCCESS) { failConnection("CONNECT_FAILED", "Could not enable notifications"); return@post }
162
172
  connected = true
163
- finishConnect(CraftNativeCapabilityResult(JSONObject().put("id", peer.device.address)))
173
+ finishConnect(CraftNativeCapabilityResult(JSONObject().put("id", peer.device.address).put("maxWrite", minOf(182, mtu - 3))))
164
174
  } }
165
175
  override fun onCharacteristicWrite(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) { handler.post {
166
- if (peer === gatt && characteristic === writer && status != BluetoothGatt.GATT_SUCCESS) finishExchange(failure("WRITE_FAILED", "Bluetooth write failed"))
176
+ if (peer !== gatt || characteristic !== writer) return@post
177
+ if (status != BluetoothGatt.GATT_SUCCESS) {
178
+ finishWrite(failure("WRITE_FAILED", "Bluetooth write failed"))
179
+ finishExchange(failure("WRITE_FAILED", "Bluetooth write failed"))
180
+ } else finishWrite(CraftNativeCapabilityResult(true))
167
181
  } }
168
182
  override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
169
183
  val bytes = characteristic.value?.copyOf() ?: return
170
184
  handler.post {
171
- if (peer !== gatt || characteristic !== notifier || exchangeDone == null) return@post
185
+ if (peer !== gatt || characteristic !== notifier) return@post
186
+ if (streaming) {
187
+ if (bytes.isEmpty() || bytes.size > 4096) { disconnect(); return@post }
188
+ if (readDone != null) { finishRead(CraftNativeCapabilityResult(JSONArray(bytes.map { it.toInt() and 255 }))); return@post }
189
+ if (notifications.size >= 512 || notificationBytes + bytes.size > 1048576) { disconnect(); return@post }
190
+ notifications.addLast(bytes); notificationBytes += bytes.size
191
+ return@post
192
+ }
193
+ if (exchangeDone == null) return@post
172
194
  if (bytes.size < responsePrefix.size || !bytes.take(responsePrefix.size).toByteArray().contentEquals(responsePrefix)) return@post
173
195
  if (bytes.size > 4096) finishExchange(failure("INVALID_RESPONSE", "Bluetooth response is too large"))
174
196
  else finishExchange(CraftNativeCapabilityResult(JSONArray(bytes.map { it.toInt() and 255 })))
@@ -185,7 +207,7 @@ internal class CraftBluetooth(private val context: Context) {
185
207
  }
186
208
  }
187
209
  private fun exchange(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
188
- if (exchangeDone != null) { done(failure("BUSY", "A Bluetooth request is running")); return }
210
+ if (streaming || exchangeDone != null) { done(failure("BUSY", "A Bluetooth request is running or streaming")); return }
189
211
  val peer = gatt; val write = writer
190
212
  if (!connected || peer == null || write == null) { done(failure("DISCONNECTED", "Bluetooth device is disconnected")); return }
191
213
  val bytes = byteArray(options, "bytes", 182)
@@ -201,10 +223,43 @@ internal class CraftBluetooth(private val context: Context) {
201
223
  exchangeTimer?.let(handler::removeCallbacks); exchangeTimer = null; responsePrefix = byteArrayOf()
202
224
  val done = exchangeDone; exchangeDone = null; done?.invoke(result)
203
225
  }
226
+ private fun finishRead(result: CraftNativeCapabilityResult) {
227
+ readTimer?.let(handler::removeCallbacks); readTimer = null
228
+ val done = readDone; readDone = null; done?.invoke(result)
229
+ }
230
+ private fun finishWrite(result: CraftNativeCapabilityResult) {
231
+ writeTimer?.let(handler::removeCallbacks); writeTimer = null
232
+ val done = writeDone; writeDone = null; done?.invoke(result)
233
+ }
234
+ private fun read(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
235
+ if (!streaming || !connected) { done(failure("DISCONNECTED", "Bluetooth stream disconnected")); return }
236
+ if (readDone != null) { done(failure("BUSY", "A Bluetooth read is running")); return }
237
+ if (!notifications.isEmpty()) {
238
+ val bytes = notifications.removeFirst(); notificationBytes -= bytes.size
239
+ done(CraftNativeCapabilityResult(JSONArray(bytes.map { it.toInt() and 255 }))); return
240
+ }
241
+ readDone = done
242
+ readTimer = timer(options.optLong("timeoutMs", 10000).coerceIn(1, 20000)) { finishRead(failure("TIMEOUT", "Bluetooth read timed out")) }
243
+ }
244
+ private fun write(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
245
+ val peer = gatt; val write = writer
246
+ if (!streaming || !connected || peer == null || write == null) { done(failure("DISCONNECTED", "Bluetooth stream disconnected")); return }
247
+ if (writeDone != null) { done(failure("BUSY", "A Bluetooth write is running")); return }
248
+ val bytes = byteArray(options, "bytes", 182)
249
+ require(bytes.size <= mtu - 3)
250
+ write.writeType = if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT else BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
251
+ writeDone = done
252
+ writeTimer = timer(10000) { finishWrite(failure("TIMEOUT", "Bluetooth write timed out")); disconnect() }
253
+ write.value = bytes
254
+ if (!peer.writeCharacteristic(write)) finishWrite(failure("WRITE_FAILED", "Bluetooth write queue is busy"))
255
+ }
204
256
  fun disconnect() {
205
257
  stopScan(failure("CANCELLED", "Bluetooth scan cancelled"))
206
258
  finishConnect(failure("CANCELLED", "Bluetooth connection cancelled"))
207
259
  finishExchange(failure("DISCONNECTED", "Bluetooth device disconnected"))
260
+ finishRead(failure("DISCONNECTED", "Bluetooth stream closed"))
261
+ finishWrite(failure("DISCONNECTED", "Bluetooth stream closed"))
262
+ notifications.clear(); notificationBytes = 0; streaming = false
208
263
  val old = gatt; gatt = null; connected = false; writer = null; notifier = null; mtu = 23
209
264
  runCatching { old?.disconnect(); old?.close() }
210
265
  }
@@ -212,6 +212,24 @@ private data class CraftNativeLength(
212
212
  }
213
213
  }
214
214
 
215
+ /** A screen root owns its viewport unless the document explicitly sizes it.
216
+ * Keep this parent constraint out of the protocol document, and apply it to
217
+ * targeted style updates too. An unstyled fragment with flex children would
218
+ * otherwise measure at their zero flex bases and collapse the entire screen. */
219
+ internal fun craftNativeScreenRootStyle(style: JSONObject): JSONObject {
220
+ val rootStyle = JSONObject()
221
+ val keys = style.keys()
222
+ while (keys.hasNext()) {
223
+ val key = keys.next()
224
+ rootStyle.put(key, style.opt(key))
225
+ }
226
+ if (rootStyle.isNull("height")) {
227
+ if (rootStyle.isNull("flex")) rootStyle.put("flex", 1)
228
+ rootStyle.put("height", "100%")
229
+ }
230
+ return rootStyle
231
+ }
232
+
215
233
  private data class CraftNativeLayoutStyle(
216
234
  val width: CraftNativeLength? = null,
217
235
  val height: CraftNativeLength? = null,
@@ -1132,14 +1150,23 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1132
1150
  invalidateMeasurements()
1133
1151
  }
1134
1152
 
1153
+ private var viewportChild: View? = null
1154
+
1155
+ fun setViewportChild(view: View?) {
1156
+ viewportChild = view
1157
+ invalidateMeasurements()
1158
+ }
1159
+
1135
1160
  fun setChildStyle(view: View, style: JSONObject, density: Float) {
1136
- styles[view] = CraftNativeLayoutStyle.from(style, density)
1161
+ val effective = if (view === viewportChild) craftNativeScreenRootStyle(style) else style
1162
+ styles[view] = CraftNativeLayoutStyle.from(effective, density)
1137
1163
  invalidateMeasurements()
1138
1164
  invalidateMeasurementAncestors(parent)
1139
1165
  }
1140
1166
 
1141
1167
  override fun onViewRemoved(child: View) {
1142
1168
  val parentBeforeRemoval = parent
1169
+ if (child === viewportChild) viewportChild = null
1143
1170
  styles.remove(child)
1144
1171
  super.onViewRemoved(child)
1145
1172
  invalidateMeasurements()
@@ -5434,6 +5461,7 @@ class MainActivity : AppCompatActivity() {
5434
5461
  screen.controls.clear()
5435
5462
  screen.controls.putAll(next)
5436
5463
  screen.handlers.keys.retainAll(retainedViews(screen))
5464
+ screen.view.setViewportChild(tree)
5437
5465
  screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
5438
5466
  promoteModalOverlays(screen)
5439
5467
  updateSafeAreaViews()
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.351";
3050
+ var version = "0.0.353";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.351";
378
+ var version = "0.0.353";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.351";
120
+ var version = "0.0.353";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.351",
3
+ "version": "0.0.353",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",