craft-native 0.0.177 → 0.0.179

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.
@@ -25,6 +25,7 @@ export interface CraftAndroidConfig {
25
25
  enableSecureStorage?: boolean;
26
26
  enableGeolocation?: boolean;
27
27
  enableBackgroundLocation?: boolean;
28
+ enableBluetooth?: boolean;
28
29
  enableHealthConnect?: boolean;
29
30
  /**
30
31
  * The Health Connect data the app reads and writes, by the shared type
@@ -183,6 +183,7 @@ var DEFAULT_CONFIG = {
183
183
  enableSecureStorage: false,
184
184
  enableGeolocation: false,
185
185
  enableBackgroundLocation: false,
186
+ enableBluetooth: false,
186
187
  enableHealthConnect: false,
187
188
  enableKeepAwake: false,
188
189
  enableDeepLinks: false,
@@ -371,7 +372,15 @@ function renderAndroidPermissions(config) {
371
372
  healthConnectPermissions(config).forEach((permission) => permissions.add(permission));
372
373
  if (config.renderer === "native" && config.backgroundRefresh?.enabled)
373
374
  permissions.add("android.permission.RECEIVE_BOOT_COMPLETED");
374
- return [...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`).join(`
375
+ const bluetooth = config.enableBluetooth ? [
376
+ ' <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />',
377
+ ' <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />',
378
+ ' <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />',
379
+ ' <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />',
380
+ ...config.enableGeolocation ? [] : [' <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />']
381
+ ].join(`
382
+ `) : "";
383
+ return [bluetooth, ...[...permissions].map((permission) => ` <uses-permission android:name="${permission}" />`)].filter(Boolean).join(`
375
384
  `);
376
385
  }
377
386
  function writeNativeShellSources(output, packagePath, packageName, config) {
@@ -380,6 +389,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
380
389
  const source = readFileSync(join(TEMPLATES_DIR, template), "utf-8").replace(/\{\{PACKAGE_NAME\}\}/g, packageName);
381
390
  writeFileSync(join(javaDir, file), replace(source));
382
391
  };
392
+ write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
383
393
  write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
384
394
  write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
385
395
  write(config.enableHealthConnect ? "CraftNativeHealth.kt.template" : "CraftNativeHealthStub.kt.template", "CraftNativeHealth.kt");
@@ -0,0 +1,211 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.bluetooth.*
6
+ import android.bluetooth.le.*
7
+ import android.content.Context
8
+ import android.content.pm.PackageManager
9
+ import android.os.Build
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.os.ParcelUuid
13
+ import androidx.core.app.ActivityCompat
14
+ import androidx.core.content.ContextCompat
15
+ import org.json.JSONArray
16
+ import org.json.JSONObject
17
+ import java.util.UUID
18
+
19
+ /** A selected GATT channel; one request/notification exchange at a time. */
20
+ @Suppress("MissingPermission", "DEPRECATION")
21
+ internal class CraftBluetooth(private val context: Context) {
22
+ private val handler = Handler(Looper.getMainLooper())
23
+ private val adapter get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
24
+ private val devices = linkedMapOf<String, BluetoothDevice>()
25
+ private val rows = linkedMapOf<String, JSONObject>()
26
+ private var scanDone: ((CraftNativeCapabilityResult) -> Unit)? = null
27
+ private var permissionScan: JSONObject? = null
28
+ private var prefix = ""
29
+ private var gatt: BluetoothGatt? = null
30
+ private var writer: BluetoothGattCharacteristic? = null
31
+ private var notifier: BluetoothGattCharacteristic? = null
32
+ private var serviceId: UUID? = null
33
+ private var writeId: UUID? = null
34
+ private var notifyId: UUID? = null
35
+ private var connected = false
36
+ private var mtu = 23
37
+ private var connectDone: ((CraftNativeCapabilityResult) -> Unit)? = null
38
+ private var exchangeDone: ((CraftNativeCapabilityResult) -> Unit)? = null
39
+ private var responsePrefix = byteArrayOf()
40
+ private var scanTimer: Runnable? = null
41
+ private var connectTimer: Runnable? = null
42
+ private var exchangeTimer: Runnable? = null
43
+ private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
44
+ private fun timer(ms: Long, action: () -> Unit) = Runnable(action).also { handler.postDelayed(it, ms) }
45
+ private fun permitted() = permissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED }
46
+ private fun permissions() = if (Build.VERSION.SDK_INT >= 31) arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) else arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
47
+
48
+ fun perform(method: String, args: JSONArray, done: (CraftNativeCapabilityResult) -> Unit) {
49
+ handler.post {
50
+ val options = args.optJSONObject(0) ?: JSONObject()
51
+ try {
52
+ when (method) {
53
+ "getState" -> done(CraftNativeCapabilityResult(JSONObject().put("available", adapter != null).put("connected", connected).put("scanning", scanDone != null)))
54
+ "scan" -> scan(options, done)
55
+ "stopScan" -> { stopScan(failure("CANCELLED", "Bluetooth scan cancelled")); done(CraftNativeCapabilityResult(true)) }
56
+ "connect" -> connect(options, done)
57
+ "exchange" -> exchange(options, done)
58
+ "disconnect" -> { disconnect(); done(CraftNativeCapabilityResult(true)) }
59
+ else -> done(failure("UNKNOWN_ACTION", "Unsupported Bluetooth method"))
60
+ }
61
+ } catch (_: SecurityException) { disconnect(); done(failure("NOT_AUTHORIZED", "Bluetooth permission is required")) }
62
+ catch (_: IllegalArgumentException) { done(failure("INVALID_ARGUMENT", "Invalid Bluetooth channel options")) }
63
+ }
64
+ }
65
+
66
+ private fun scan(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
67
+ if (scanDone != null || connectDone != null || exchangeDone != null) { done(failure("BUSY", "A Bluetooth operation is running")); return }
68
+ val services = options.optJSONArray("services") ?: throw IllegalArgumentException()
69
+ require(services.length() in 1..8)
70
+ (0 until services.length()).forEach { UUID.fromString(services.getString(it)) }
71
+ scanDone = done
72
+ if (!permitted()) {
73
+ val activity = context as? Activity
74
+ if (activity == null) { stopScan(failure("NEEDS_ACTIVITY", "Bluetooth setup needs a screen")); return }
75
+ permissionScan = options
76
+ ActivityCompat.requestPermissions(activity, permissions(), 4317)
77
+ return
78
+ }
79
+ startScan(options)
80
+ }
81
+
82
+ fun onPermissions(requestCode: Int) {
83
+ if (requestCode != 4317) return
84
+ val options = permissionScan ?: return
85
+ permissionScan = null
86
+ if (!permitted()) stopScan(failure("NOT_AUTHORIZED", "Allow Nearby devices to find your bottle"))
87
+ else try { startScan(options) } catch (_: Exception) { stopScan(failure("SCAN_FAILED", "Bluetooth scan could not start")) }
88
+ }
89
+
90
+ private fun startScan(options: JSONObject) {
91
+ val radio = adapter
92
+ if (radio == null || !radio.isEnabled) { stopScan(failure("BLUETOOTH_OFF", "Turn on Bluetooth")); return }
93
+ devices.clear(); rows.clear()
94
+ prefix = options.optString("namePrefix")
95
+ val services = options.getJSONArray("services")
96
+ val filters = (0 until services.length()).map { ScanFilter.Builder().setServiceUuid(ParcelUuid(UUID.fromString(services.getString(it)))).build() }
97
+ radio.bluetoothLeScanner.startScan(filters, ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build(), scanner)
98
+ scanTimer = timer(options.optLong("timeoutMs", 10000).coerceIn(1000, 20000)) {
99
+ stopScan(CraftNativeCapabilityResult(JSONArray(rows.values.toList())))
100
+ }
101
+ }
102
+ private val scanner = object : ScanCallback() {
103
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
104
+ handler.post {
105
+ if (scanDone == null || rows.size >= 64) return@post
106
+ val name = result.scanRecord?.deviceName ?: ""
107
+ if (!name.startsWith(prefix, ignoreCase = true)) return@post
108
+ val id = result.device.address
109
+ devices[id] = result.device
110
+ rows[id] = JSONObject().put("id", id).put("name", name).put("rssi", result.rssi)
111
+ }
112
+ }
113
+ override fun onScanFailed(errorCode: Int) { handler.post { stopScan(failure("SCAN_FAILED", "Bluetooth scan failed ($errorCode)")) } }
114
+ }
115
+ private fun stopScan(result: CraftNativeCapabilityResult) {
116
+ if (permitted()) runCatching { adapter?.bluetoothLeScanner?.stopScan(scanner) }
117
+ scanTimer?.let(handler::removeCallbacks); scanTimer = null; permissionScan = null
118
+ val done = scanDone; scanDone = null; done?.invoke(result)
119
+ }
120
+
121
+ private fun connect(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
122
+ if (scanDone != null || connectDone != null || exchangeDone != null) { done(failure("BUSY", "A Bluetooth operation is running")); return }
123
+ if (!permitted()) { done(failure("NOT_AUTHORIZED", "Bluetooth permission is required")); return }
124
+ val device = devices[options.optString("id")] ?: throw IllegalArgumentException()
125
+ disconnect()
126
+ serviceId = UUID.fromString(options.getString("service")); writeId = UUID.fromString(options.getString("write")); notifyId = UUID.fromString(options.getString("notify"))
127
+ connectDone = done
128
+ gatt = device.connectGatt(context, false, callbacks, BluetoothDevice.TRANSPORT_LE)
129
+ connectTimer = timer(15000) { failConnection("TIMEOUT", "Device did not connect; wake it and try again") }
130
+ }
131
+ private fun finishConnect(result: CraftNativeCapabilityResult) {
132
+ connectTimer?.let(handler::removeCallbacks); connectTimer = null
133
+ val done = connectDone; connectDone = null; done?.invoke(result)
134
+ }
135
+ private fun failConnection(code: String, message: String) { finishConnect(failure(code, message)); disconnect() }
136
+ private val callbacks = object : BluetoothGattCallback() {
137
+ override fun onConnectionStateChange(peer: BluetoothGatt, status: Int, newState: Int) { handler.post {
138
+ if (peer !== gatt) return@post
139
+ if (status != BluetoothGatt.GATT_SUCCESS || newState == BluetoothProfile.STATE_DISCONNECTED) { failConnection("DISCONNECTED", "Bluetooth device disconnected"); return@post }
140
+ if (newState == BluetoothProfile.STATE_CONNECTED && !peer.requestMtu(247)) failConnection("MTU_TOO_SMALL", "Could not negotiate Bluetooth MTU")
141
+ } }
142
+ override fun onMtuChanged(peer: BluetoothGatt, value: Int, status: Int) { handler.post {
143
+ 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 }
145
+ mtu = value
146
+ if (!peer.discoverServices()) failConnection("CONNECT_FAILED", "Could not discover Bluetooth services")
147
+ } }
148
+ override fun onServicesDiscovered(peer: BluetoothGatt, status: Int) { handler.post {
149
+ if (peer !== gatt) return@post
150
+ val service = serviceId?.let(peer::getService)
151
+ writer = writeId?.let { service?.getCharacteristic(it) }; notifier = notifyId?.let { service?.getCharacteristic(it) }
152
+ val write = writer; val notify = notifier
153
+ if (status != BluetoothGatt.GATT_SUCCESS || write == null || notify == null || write.properties and (BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE) == 0 || notify.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY == 0) { failConnection("UNSUPPORTED_DEVICE", "The selected GATT channel is missing"); return@post }
154
+ val descriptor = notify.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"))
155
+ if (descriptor == null || !peer.setCharacteristicNotification(notify, true)) { failConnection("CONNECT_FAILED", "Could not subscribe to the device"); return@post }
156
+ descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
157
+ if (!peer.writeDescriptor(descriptor)) failConnection("CONNECT_FAILED", "Could not enable notifications")
158
+ } }
159
+ override fun onDescriptorWrite(peer: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { handler.post {
160
+ if (peer !== gatt || descriptor.characteristic !== notifier) return@post
161
+ if (status != BluetoothGatt.GATT_SUCCESS) { failConnection("CONNECT_FAILED", "Could not enable notifications"); return@post }
162
+ connected = true
163
+ finishConnect(CraftNativeCapabilityResult(JSONObject().put("id", peer.device.address)))
164
+ } }
165
+ 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"))
167
+ } }
168
+ override fun onCharacteristicChanged(peer: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
169
+ val bytes = characteristic.value?.copyOf() ?: return
170
+ handler.post {
171
+ if (peer !== gatt || characteristic !== notifier || exchangeDone == null) return@post
172
+ if (bytes.size < responsePrefix.size || !bytes.take(responsePrefix.size).toByteArray().contentEquals(responsePrefix)) return@post
173
+ if (bytes.size > 4096) finishExchange(failure("INVALID_RESPONSE", "Bluetooth response is too large"))
174
+ else finishExchange(CraftNativeCapabilityResult(JSONArray(bytes.map { it.toInt() and 255 })))
175
+ }
176
+ }
177
+ }
178
+ private fun byteArray(options: JSONObject, name: String, max: Int): ByteArray {
179
+ val values = options.getJSONArray(name)
180
+ require(values.length() in 1..max)
181
+ return ByteArray(values.length()) { index ->
182
+ val value = values.get(index)
183
+ require(value is Number && value.toDouble() == value.toInt().toDouble() && value.toInt() in 0..255)
184
+ value.toInt().toByte()
185
+ }
186
+ }
187
+ private fun exchange(options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
188
+ if (exchangeDone != null) { done(failure("BUSY", "A Bluetooth request is running")); return }
189
+ val peer = gatt; val write = writer
190
+ if (!connected || peer == null || write == null) { done(failure("DISCONNECTED", "Bluetooth device is disconnected")); return }
191
+ val bytes = byteArray(options, "bytes", 182)
192
+ require(bytes.size <= mtu - 3)
193
+ responsePrefix = byteArray(options, "responsePrefix", 16)
194
+ exchangeDone = done
195
+ exchangeTimer = timer(options.optLong("timeoutMs", 10000).coerceIn(1000, 20000)) { finishExchange(failure("TIMEOUT", "Bluetooth device did not answer")) }
196
+ write.writeType = if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT else BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
197
+ write.value = bytes
198
+ if (!peer.writeCharacteristic(write)) finishExchange(failure("WRITE_FAILED", "Bluetooth write queue is busy"))
199
+ }
200
+ private fun finishExchange(result: CraftNativeCapabilityResult) {
201
+ exchangeTimer?.let(handler::removeCallbacks); exchangeTimer = null; responsePrefix = byteArrayOf()
202
+ val done = exchangeDone; exchangeDone = null; done?.invoke(result)
203
+ }
204
+ fun disconnect() {
205
+ stopScan(failure("CANCELLED", "Bluetooth scan cancelled"))
206
+ finishConnect(failure("CANCELLED", "Bluetooth connection cancelled"))
207
+ finishExchange(failure("DISCONNECTED", "Bluetooth device disconnected"))
208
+ val old = gatt; gatt = null; connected = false; writer = null; notifier = null; mtu = 23
209
+ runCatching { old?.disconnect(); old?.close() }
210
+ }
211
+ }
@@ -63,6 +63,8 @@ internal class CraftNativeCapabilities(
63
63
  ) {
64
64
  /** The screen's activity; null in a background refresh, where nothing can be shown. */
65
65
  private val activity: MainActivity? get() = context as? MainActivity
66
+ private val bluetooth by lazy { CraftBluetooth(context) }
67
+ fun onBluetoothPermissions(requestCode: Int) { if (config.optBoolean("enableBluetooth")) bluetooth.onPermissions(requestCode) }
66
68
  private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
67
69
  private val storage = context.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
68
70
  // The typed native renderer has no CraftNative Zig holder. Keep its
@@ -111,6 +113,7 @@ internal class CraftNativeCapabilities(
111
113
 
112
114
  fun names(): JSONArray = JSONArray().apply {
113
115
  listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "files").forEach(::put)
116
+ if (config.optBoolean("enableBluetooth")) put("bluetooth")
114
117
  if (config.optBoolean("enableBiometric")) put("biometric")
115
118
  if (config.optBoolean("enableSecureStorage")) put("secureStorage")
116
119
  if (config.optBoolean("enableLocalDatabase")) put("database")
@@ -132,6 +135,10 @@ internal class CraftNativeCapabilities(
132
135
  return
133
136
  }
134
137
  when (module) {
138
+ "Bluetooth" -> {
139
+ if (!config.optBoolean("enableBluetooth")) complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
140
+ else bluetooth.perform(method, args) { complete(requestToken, it, completion) }
141
+ }
135
142
  "Storage" -> complete(requestToken, storage(method, args), completion)
136
143
  "SecureStorage" -> {
137
144
  if (!config.optBoolean("enableSecureStorage")) complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
@@ -177,6 +184,7 @@ internal class CraftNativeCapabilities(
177
184
  }
178
185
 
179
186
  fun close() {
187
+ if (config.optBoolean("enableBluetooth")) bluetooth.disconnect()
180
188
  databaseExecutor.shutdownNow()
181
189
  networkExecutor.shutdownNow()
182
190
  fetchConnections.values.forEach(HttpURLConnection::disconnect)
@@ -856,15 +856,18 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
856
856
  requestLayout()
857
857
  }
858
858
 
859
- private fun invalidateMeasurementAncestors(start: ViewParent?) {
860
- var ancestor = start
861
- while (ancestor != null) {
862
- when (ancestor) {
863
- is CraftNativeFlexLayout -> ancestor.invalidateMeasurements()
864
- is CraftNativeKeyboardAvoidingView -> ancestor.content.invalidateMeasurements()
865
- is CraftNativeScrollView -> ancestor.invalidateContentLayout()
859
+ companion object {
860
+ /** Clears the memoized measurements of every flex host above `start`. The activity calls it too (an image whose source changed). */
861
+ fun invalidateMeasurementAncestors(start: ViewParent?) {
862
+ var ancestor = start
863
+ while (ancestor != null) {
864
+ when (ancestor) {
865
+ is CraftNativeFlexLayout -> ancestor.invalidateMeasurements()
866
+ is CraftNativeKeyboardAvoidingView -> ancestor.content.invalidateMeasurements()
867
+ is CraftNativeScrollView -> ancestor.invalidateContentLayout()
868
+ }
869
+ ancestor = (ancestor as? View)?.parent
866
870
  }
867
- ancestor = (ancestor as? View)?.parent
868
871
  }
869
872
  }
870
873
 
@@ -4086,7 +4089,7 @@ class MainActivity : AppCompatActivity() {
4086
4089
  includeFontPadding = false
4087
4090
  configureText(this, craftNativeOptionalString(props.opt("title")) ?: childText(children), style)
4088
4091
  val linkColor = craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color"))
4089
- setTextColor(color(linkColor, Color.rgb(33, 150, 243)))
4092
+ setTextColor(color(linkColor.orEmpty(), Color.rgb(33, 150, 243)))
4090
4093
  paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
4091
4094
  isEnabled = !props.optBoolean("disabled", false)
4092
4095
  isClickable = true
@@ -4369,7 +4372,7 @@ class MainActivity : AppCompatActivity() {
4369
4372
  } else {
4370
4373
  craftNativeOptionalString(events.opt("onDismiss"))
4371
4374
  }
4372
- if (lifecycleHandler.isNotBlank() && !suppressDismiss) {
4375
+ if (!lifecycleHandler.isNullOrBlank() && !suppressDismiss) {
4373
4376
  // Deliver lifecycle callbacks after reconciliation returns to
4374
4377
  // the queue; the handler may immediately schedule another
4375
4378
  // render against this native view.
@@ -5452,7 +5455,7 @@ class MainActivity : AppCompatActivity() {
5452
5455
  private fun imageDidChange(view: ImageView) {
5453
5456
  view.invalidate()
5454
5457
  view.requestLayout()
5455
- invalidateMeasurementAncestors(view.parent)
5458
+ CraftNativeFlexLayout.invalidateMeasurementAncestors(view.parent)
5456
5459
  }
5457
5460
 
5458
5461
  private fun emitImageEvent(screen: Screen, view: ImageView, handler: String?, nativeEvent: JSONObject = JSONObject()) {
@@ -5774,6 +5777,7 @@ class MainActivity : AppCompatActivity() {
5774
5777
 
5775
5778
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
5776
5779
  super.onRequestPermissionsResult(requestCode, permissions, grantResults)
5780
+ if (::capabilities.isInitialized) capabilities.onBluetoothPermissions(requestCode)
5777
5781
  if (::services.isInitialized) services.onRequestPermissionsResult(requestCode)
5778
5782
  }
5779
5783
 
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.177";
3306
+ var version = "0.0.179";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.177";
313
+ var version = "0.0.179";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.177";
61
+ var version = "0.0.179";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -1003,6 +1003,7 @@ function writeShell(output, config) {
1003
1003
  cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
1004
1004
  cpSync(join3(TEMPLATES_DIR, "CraftHealth.swift"), join3(output, "Sources", "CraftHealth.swift"));
1005
1005
  cpSync(join3(TEMPLATES_DIR, "CraftMusic.swift"), join3(output, "Sources", "CraftMusic.swift"));
1006
+ cpSync(join3(TEMPLATES_DIR, "CraftBluetooth.swift"), join3(output, "Sources", "CraftBluetooth.swift"));
1006
1007
  cpSync(join3(TEMPLATES_DIR, "CraftHeartRate.swift"), join3(output, "Sources", "CraftHeartRate.swift"));
1007
1008
  cpSync(join3(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join3(output, "Sources", "CraftScheduledWorkouts.swift"));
1008
1009
  cpSync(join3(TEMPLATES_DIR, "CraftNativeMutation.swift"), join3(output, "Sources", "CraftNativeMutation.swift"));
@@ -2564,6 +2564,17 @@ struct CraftWebView: UIViewRepresentable {
2564
2564
  rejectCallback(callbackId, error: "Local database is disabled", code: "CAPABILITY_DISABLED")
2565
2565
  }
2566
2566
  // MARK: - Bluetooth
2567
+ case "bluetoothChannel":
2568
+ guard config.enableBluetooth else {
2569
+ rejectCallback(callbackId, error: "Bluetooth is disabled", code: "CAPABILITY_DISABLED")
2570
+ break
2571
+ }
2572
+ CraftBluetooth.shared.perform(method: body["method"] as? String ?? "", args: [body["options"] as? [String: Any] ?? [:]]) { [weak self] result in
2573
+ switch result {
2574
+ case .success(let value): self?.resolveCallback(callbackId, result: value)
2575
+ case .failure(let error): self?.rejectCallback(callbackId, error: error.message, code: error.code)
2576
+ }
2577
+ }
2567
2578
  case "startBluetoothScan":
2568
2579
  if config.enableBluetooth {
2569
2580
  startBluetoothScan(callbackId: callbackId)
@@ -5288,6 +5299,14 @@ struct CraftWebView: UIViewRepresentable {
5288
5299
  // natively, screen locked or app closed, until stopRecording().
5289
5300
  (function() {
5290
5301
  function heartRate(method, options) { return craft._invoke('heartRate', {method: method, options: options || {}}); }
5302
+ craft.bluetooth = {
5303
+ getState: function() { return craft._invoke('bluetoothChannel', {method: 'getState'}); },
5304
+ scan: function(options) { return craft._invoke('bluetoothChannel', {method: 'scan', options: options || {}}); },
5305
+ stopScan: function() { return craft._invoke('bluetoothChannel', {method: 'stopScan'}); },
5306
+ connect: function(options) { return craft._invoke('bluetoothChannel', {method: 'connect', options: options || {}}); },
5307
+ exchange: function(options) { return craft._invoke('bluetoothChannel', {method: 'exchange', options: options || {}}); },
5308
+ disconnect: function() { return craft._invoke('bluetoothChannel', {method: 'disconnect'}); }
5309
+ };
5291
5310
  craft.heartRate = {
5292
5311
  getState: function() { return heartRate('getState'); },
5293
5312
  requestAuthorization: function() { return craft._invoke('heartRateRequestAuthorization'); },
@@ -0,0 +1,243 @@
1
+ import CoreBluetooth
2
+ import Foundation
3
+
4
+ struct CraftBluetoothError: Error {
5
+ let message: String
6
+ let code: String
7
+ }
8
+
9
+ /// One explicitly chosen GATT channel. Scanning is user initiated; no vendor API.
10
+ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
11
+ typealias Completion = (Result<Any, CraftBluetoothError>) -> Void
12
+ static let shared = CraftBluetooth()
13
+ private var central: CBCentralManager?
14
+ private var devices: [String: CBPeripheral] = [:]
15
+ private var names: [String: String] = [:]
16
+ private var strengths: [String: Int] = [:]
17
+ private var scanServices: [CBUUID] = []
18
+ private var namePrefix = ""
19
+ private var scanCompletion: Completion?
20
+ private var scanTimer: DispatchWorkItem?
21
+ private var peripheral: CBPeripheral?
22
+ private var serviceUUID: CBUUID?
23
+ private var writeUUID: CBUUID?
24
+ private var notifyUUID: CBUUID?
25
+ private var writer: CBCharacteristic?
26
+ private var notifier: CBCharacteristic?
27
+ private var connectCompletion: Completion?
28
+ private var connectTimer: DispatchWorkItem?
29
+ private var exchangeCompletion: Completion?
30
+ private var exchangeTimer: DispatchWorkItem?
31
+ private var responsePrefix = Data()
32
+ private var connected = false
33
+
34
+ func perform(method: String, args: [Any], completion: @escaping Completion) {
35
+ if !Thread.isMainThread {
36
+ DispatchQueue.main.async { self.perform(method: method, args: args, completion: completion) }
37
+ return
38
+ }
39
+ let options = args.first as? [String: Any] ?? [:]
40
+ switch method {
41
+ case "getState":
42
+ completion(.success(["available": true, "connected": connected, "scanning": scanCompletion != nil]))
43
+ case "scan": scan(options, completion)
44
+ case "stopScan": stopScan(); completion(.success(true))
45
+ case "connect": connect(options, completion)
46
+ case "exchange": exchange(options, completion)
47
+ case "disconnect": disconnect(); completion(.success(true))
48
+ default: reject(completion, "Unsupported Bluetooth method", "UNKNOWN_ACTION")
49
+ }
50
+ }
51
+
52
+ private func reject(_ completion: Completion, _ message: String, _ code: String) {
53
+ completion(.failure(CraftBluetoothError(message: message, code: code)))
54
+ }
55
+
56
+ private func ensureCentral() {
57
+ if central == nil { central = CBCentralManager(delegate: self, queue: .main) }
58
+ }
59
+
60
+ private func scan(_ options: [String: Any], _ completion: @escaping Completion) {
61
+ guard scanCompletion == nil, connectCompletion == nil, exchangeCompletion == nil else {
62
+ return reject(completion, "A Bluetooth operation is already running", "BUSY")
63
+ }
64
+ guard let services = options["services"] as? [String], !services.isEmpty, services.count <= 8,
65
+ services.allSatisfy({ UUID(uuidString: $0) != nil }) else {
66
+ return reject(completion, "Scan needs full service UUIDs", "INVALID_ARGUMENT")
67
+ }
68
+ let timeout = min(20, max(1, (options["timeoutMs"] as? Double ?? 10000) / 1000))
69
+ scanServices = services.map(CBUUID.init(string:))
70
+ namePrefix = options["namePrefix"] as? String ?? ""
71
+ devices.removeAll(); names.removeAll(); strengths.removeAll()
72
+ scanCompletion = completion
73
+ ensureCentral()
74
+ if central?.state == .poweredOn { startScan() }
75
+ else if let state = central?.state, state != .unknown, state != .resetting { rejectRadio(state); return }
76
+ let timer = DispatchWorkItem { [weak self] in self?.finishScan() }
77
+ scanTimer = timer
78
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timer)
79
+ }
80
+
81
+ private func startScan() {
82
+ guard scanCompletion != nil else { return }
83
+ central?.scanForPeripherals(withServices: scanServices, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
84
+ }
85
+
86
+ private func finishScan() {
87
+ central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
88
+ let completion = scanCompletion; scanCompletion = nil
89
+ let rows: [[String: Any]] = devices.keys.sorted().map { ["id": $0, "name": names[$0] ?? "", "rssi": strengths[$0] ?? -127] }
90
+ completion?(.success(rows))
91
+ }
92
+
93
+ private func stopScan() {
94
+ central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
95
+ let completion = scanCompletion; scanCompletion = nil
96
+ if let completion { reject(completion, "Bluetooth scan cancelled", "CANCELLED") }
97
+ }
98
+
99
+ private func rejectRadio(_ state: CBManagerState) {
100
+ let code = state == .unauthorized ? "NOT_AUTHORIZED" : state == .poweredOff ? "BLUETOOTH_OFF" : "UNSUPPORTED"
101
+ let failure = CraftBluetoothError(message: "Bluetooth is not available", code: code)
102
+ central?.stopScan(); scanTimer?.cancel(); scanTimer = nil
103
+ let scan = scanCompletion; scanCompletion = nil; scan?(.failure(failure))
104
+ settleConnect(.failure(failure)); settleExchange(.failure(failure))
105
+ connected = false
106
+ }
107
+
108
+ func centralManagerDidUpdateState(_ central: CBCentralManager) {
109
+ if central.state == .poweredOn { startScan() }
110
+ else if central.state != .unknown, central.state != .resetting { rejectRadio(central.state) }
111
+ }
112
+
113
+ func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
114
+ guard scanCompletion != nil, devices.count < 64 else { return }
115
+ let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name ?? ""
116
+ guard namePrefix.isEmpty || name.lowercased().hasPrefix(namePrefix.lowercased()) else { return }
117
+ let id = peripheral.identifier.uuidString
118
+ devices[id] = peripheral; names[id] = name; strengths[id] = RSSI.intValue
119
+ }
120
+
121
+ private func connect(_ options: [String: Any], _ completion: @escaping Completion) {
122
+ guard scanCompletion == nil, connectCompletion == nil, exchangeCompletion == nil else {
123
+ return reject(completion, "A Bluetooth operation is already running", "BUSY")
124
+ }
125
+ guard let id = options["id"] as? String, let found = devices[id],
126
+ let service = options["service"] as? String, UUID(uuidString: service) != nil,
127
+ let write = options["write"] as? String, UUID(uuidString: write) != nil,
128
+ let notify = options["notify"] as? String, UUID(uuidString: notify) != nil else {
129
+ return reject(completion, "Choose a nearby device and its GATT channel", "INVALID_ARGUMENT")
130
+ }
131
+ guard central?.state == .poweredOn else { return reject(completion, "Bluetooth is off", "BLUETOOTH_OFF") }
132
+ disconnect()
133
+ peripheral = found; serviceUUID = CBUUID(string: service); writeUUID = CBUUID(string: write); notifyUUID = CBUUID(string: notify)
134
+ found.delegate = self; connectCompletion = completion
135
+ central?.connect(found, options: nil)
136
+ let timer = DispatchWorkItem { [weak self] in
137
+ guard let self else { return }
138
+ self.settleConnect(.failure(CraftBluetoothError(message: "Device did not connect; wake it and try again", code: "TIMEOUT")))
139
+ self.disconnect()
140
+ }
141
+ connectTimer = timer
142
+ DispatchQueue.main.asyncAfter(deadline: .now() + 15, execute: timer)
143
+ }
144
+
145
+ private func settleConnect(_ result: Result<Any, CraftBluetoothError>) {
146
+ connectTimer?.cancel(); connectTimer = nil
147
+ let completion = connectCompletion; connectCompletion = nil; completion?(result)
148
+ }
149
+
150
+ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
151
+ guard peripheral === self.peripheral, let serviceUUID else { return }
152
+ peripheral.discoverServices([serviceUUID])
153
+ }
154
+
155
+ func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
156
+ guard peripheral === self.peripheral else { return }
157
+ settleConnect(.failure(CraftBluetoothError(message: "Bluetooth connection failed", code: "CONNECT_FAILED")))
158
+ disconnect()
159
+ }
160
+
161
+ func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
162
+ guard peripheral === self.peripheral else { return }
163
+ connected = false; writer = nil; notifier = nil
164
+ settleConnect(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
165
+ settleExchange(.failure(CraftBluetoothError(message: "Bluetooth device disconnected", code: "DISCONNECTED")))
166
+ }
167
+
168
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
169
+ guard peripheral === self.peripheral else { return }
170
+ guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == serviceUUID }), let writeUUID, let notifyUUID else {
171
+ settleConnect(.failure(CraftBluetoothError(message: "The selected GATT service is missing", code: "UNSUPPORTED_DEVICE"))); disconnect(); return
172
+ }
173
+ peripheral.discoverCharacteristics([writeUUID, notifyUUID], for: service)
174
+ }
175
+
176
+ func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
177
+ guard peripheral === self.peripheral else { return }
178
+ writer = service.characteristics?.first(where: { $0.uuid == writeUUID })
179
+ notifier = service.characteristics?.first(where: { $0.uuid == notifyUUID })
180
+ guard error == nil, let writer, let notifier, writer.properties.contains(.write) || writer.properties.contains(.writeWithoutResponse), notifier.properties.contains(.notify) else {
181
+ settleConnect(.failure(CraftBluetoothError(message: "The selected GATT channel is missing", code: "UNSUPPORTED_DEVICE"))); disconnect(); return
182
+ }
183
+ peripheral.setNotifyValue(true, for: notifier)
184
+ }
185
+
186
+ func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
187
+ guard peripheral === self.peripheral, characteristic === notifier else { return }
188
+ guard error == nil, characteristic.isNotifying else {
189
+ settleConnect(.failure(CraftBluetoothError(message: "Could not subscribe to the device", code: "CONNECT_FAILED"))); disconnect(); return
190
+ }
191
+ connected = true
192
+ let id = peripheral.identifier.uuidString
193
+ settleConnect(.success(["id": id, "name": names[id] ?? peripheral.name ?? ""]))
194
+ }
195
+
196
+ private func exchange(_ options: [String: Any], _ completion: @escaping Completion) {
197
+ guard exchangeCompletion == nil else { return reject(completion, "A Bluetooth request is already running", "BUSY") }
198
+ guard connected, let peripheral, let writer else { return reject(completion, "Bluetooth device is disconnected", "DISCONNECTED") }
199
+ guard let bytes = options["bytes"] as? [Int], !bytes.isEmpty, bytes.count <= 182, bytes.allSatisfy({ (0...255).contains($0) }),
200
+ let prefix = options["responsePrefix"] as? [Int], !prefix.isEmpty, prefix.count <= 16, prefix.allSatisfy({ (0...255).contains($0) }) else {
201
+ return reject(completion, "Invalid Bluetooth request bytes", "INVALID_ARGUMENT")
202
+ }
203
+ let type: CBCharacteristicWriteType = writer.properties.contains(.write) ? .withResponse : .withoutResponse
204
+ guard bytes.count <= peripheral.maximumWriteValueLength(for: type) else { return reject(completion, "Bluetooth request exceeds the negotiated MTU", "MTU_TOO_SMALL") }
205
+ if type == .withoutResponse, !peripheral.canSendWriteWithoutResponse { return reject(completion, "Bluetooth write queue is busy; retry", "BUSY") }
206
+ responsePrefix = Data(prefix.map(UInt8.init)); exchangeCompletion = completion
207
+ let timeout = min(20, max(1, (options["timeoutMs"] as? Double ?? 10000) / 1000))
208
+ let timer = DispatchWorkItem { [weak self] in self?.settleExchange(.failure(CraftBluetoothError(message: "Bluetooth device did not answer", code: "TIMEOUT"))) }
209
+ exchangeTimer = timer
210
+ DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timer)
211
+ peripheral.writeValue(Data(bytes.map(UInt8.init)), for: writer, type: type)
212
+ }
213
+
214
+ private func settleExchange(_ result: Result<Any, CraftBluetoothError>) {
215
+ exchangeTimer?.cancel(); exchangeTimer = nil
216
+ let completion = exchangeCompletion; exchangeCompletion = nil; responsePrefix = Data(); completion?(result)
217
+ }
218
+
219
+ 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")))
222
+ }
223
+
224
+ func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
225
+ guard peripheral === self.peripheral, characteristic === notifier, exchangeCompletion != nil else { return }
226
+ guard error == nil, let data = characteristic.value else {
227
+ settleExchange(.failure(CraftBluetoothError(message: "Bluetooth notification failed", code: "READ_FAILED"))); return
228
+ }
229
+ guard data.starts(with: responsePrefix) else { return }
230
+ guard data.count <= 4096 else {
231
+ settleExchange(.failure(CraftBluetoothError(message: "Bluetooth response is too large", code: "INVALID_RESPONSE"))); return
232
+ }
233
+ settleExchange(.success(data.map(Int.init)))
234
+ }
235
+
236
+ private func disconnect() {
237
+ stopScan()
238
+ settleConnect(.failure(CraftBluetoothError(message: "Bluetooth connection cancelled", code: "CANCELLED")))
239
+ settleExchange(.failure(CraftBluetoothError(message: "Bluetooth request cancelled", code: "CANCELLED")))
240
+ if let peripheral { central?.cancelPeripheralConnection(peripheral); peripheral.delegate = nil }
241
+ peripheral = nil; writer = nil; notifier = nil; connected = false
242
+ }
243
+ }
@@ -189,7 +189,7 @@ private func craftCollectFrames(_ context: UnsafeMutableRawPointer?) -> Int {
189
189
  let returnAddress = craftStrip(record.1)
190
190
  // In a leaf function the caller is only in the link register: the
191
191
  // first saved return address then belongs to the caller's caller.
192
- if first, lr != 0, craftStrip(lr) != returnAddress, count < craftCrashMaxFrames {
192
+ if first, lr != 0, craftStrip(lr) != returnAddress, craftStrip(lr) != frames[0], count < craftCrashMaxFrames {
193
193
  frames[count] = craftStrip(lr); count += 1
194
194
  }
195
195
  first = false
@@ -478,7 +478,13 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
478
478
  let frames = addresses.map { frame(for: $0, in: images) }
479
479
  let signalName = Self.signalName(signal)
480
480
  let type = exceptionName ?? signalName
481
- var message = messages.first(where: { $0.contains("Fatal error") || $0.contains("fatal error") }) ?? messages.first ?? ""
481
+ // A runtime's own words when it trapped on purpose (Swift's "Fatal
482
+ // error: …", an assertion); an abort's first annotation otherwise.
483
+ // Never the simulator's device banner, which some image's
484
+ // __crash_info carries on every simulator process.
485
+ let deliberate = messages.first(where: { $0.localizedCaseInsensitiveContains("fatal error") || $0.contains("Assertion failed") || $0.contains("Precondition failed") })
486
+ let annotation = signal == SIGABRT ? messages.first(where: { !$0.hasPrefix("CoreSimulator") }) : nil
487
+ var message: String = deliberate ?? annotation ?? ""
482
488
  if message.isEmpty {
483
489
  message = Self.signalDescription(signal, code: code, address: faultAddress)
484
490
  }
@@ -497,7 +503,8 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
497
503
  event["timestamp"] = Self.iso(crashedAt)
498
504
  event["exception"] = exception
499
505
  event["breadcrumbs"] = Array(previousCrumbs.suffix(50))
500
- event["tags"] = ["mechanism": exception["mechanism"] as? String ?? "signal"]
506
+ let mechanism: String = exception["mechanism"] as? String ?? "signal"
507
+ event["tags"] = ["mechanism": mechanism]
501
508
  if let version = previousSession["version"] as? String, let build = previousSession["build"] as? String {
502
509
  var contexts = event["contexts"] as? [String: Any] ?? [:]
503
510
  var app = contexts["app"] as? [String: Any] ?? [:]
@@ -645,7 +652,8 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
645
652
  if let last = self.lastJavaScriptError, last.key == key, Date().timeIntervalSince(last.at) < 5 { return }
646
653
  self.lastJavaScriptError = (key, Date())
647
654
  var event = self.baseEvent(release: self.release, sessionId: self.sessionId, sessionStart: Self.iso(self.sessionStart))
648
- event["type"] = (name?.isEmpty == false ? name : nil) ?? "Error"
655
+ let type: String = name.flatMap { $0.isEmpty ? nil : $0 } ?? "Error"
656
+ event["type"] = type
649
657
  event["message"] = message
650
658
  if !stack.isEmpty, stack != "undefined" { event["stack"] = stack }
651
659
  event["level"] = "error"
@@ -757,6 +765,11 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
757
765
 
758
766
  private func baseEvent(release: String, sessionId: String?, sessionStart: String?) -> [String: Any] {
759
767
  let device = UIDevice.current
768
+ let info = Bundle.main.infoDictionary
769
+ let appName: String = info?["CFBundleDisplayName"] as? String ?? info?["CFBundleName"] as? String ?? ""
770
+ // Typed, not inline: against `Any`, `Locale.current.identifier` picks
771
+ // iOS 16's `identifier(_:)` method and stores a function.
772
+ let locale: String = Locale.current.identifier
760
773
  var event: [String: Any] = [
761
774
  "platform": "ios",
762
775
  "release": release,
@@ -768,7 +781,7 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
768
781
  "contexts": [
769
782
  "app": [
770
783
  "id": Bundle.main.bundleIdentifier ?? "",
771
- "name": Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? (Bundle.main.infoDictionary?["CFBundleName"] as? String ?? ""),
784
+ "name": appName,
772
785
  "version": appVersion,
773
786
  "build": appBuild,
774
787
  ],
@@ -776,7 +789,7 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
776
789
  "model": Self.deviceModel(),
777
790
  "family": device.model,
778
791
  "simulator": Self.isSimulator,
779
- "locale": Locale.current.identifier,
792
+ "locale": locale,
780
793
  ],
781
794
  "os": ["name": "iOS", "version": device.systemVersion],
782
795
  ],
@@ -789,8 +802,16 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
789
802
  private var craftVersion: String { "{{CRAFT_VERSION}}" }
790
803
 
791
804
  private func enqueue(_ endpoint: String, _ body: [String: Any]) {
792
- guard let outbox, JSONSerialization.isValidJSONObject(["endpoint": endpoint, "body": body]),
793
- let data = try? JSONSerialization.data(withJSONObject: ["endpoint": endpoint, "body": body]) else { return }
805
+ // Through jsonSafe first: a `[String: Any]` built in Swift can hold an
806
+ // Optional (a `??` inferred against `Any`), and a page's event can hold
807
+ // anything at all. Either made the whole event invalid JSON, and it was
808
+ // dropped without a word.
809
+ let item: [String: Any] = ["endpoint": endpoint, "body": Self.jsonSafe(body)]
810
+ guard let outbox, JSONSerialization.isValidJSONObject(item),
811
+ let data = try? JSONSerialization.data(withJSONObject: item) else {
812
+ CraftNativeConsole.write("error", category: "crash-reporter", message: "dropped an \(endpoint) event that is not JSON")
813
+ return
814
+ }
794
815
  let name = String(format: "%013.0f-%@.json", Date().timeIntervalSince1970 * 1000, UUID().uuidString)
795
816
  try? data.write(to: outbox.appendingPathComponent(name), options: .atomic)
796
817
  flush()
@@ -893,6 +914,49 @@ final class CraftCrashReporter: NSObject, MXMetricManagerSubscriber {
893
914
 
894
915
  // MARK: Helpers
895
916
 
917
+ /// `value` as something JSONSerialization accepts: Optionals unwrapped
918
+ /// (nil is null), integers of every width as numbers, dates as ISO
919
+ /// strings, and anything else as its description.
920
+ static func jsonSafe(_ value: Any) -> Any {
921
+ let mirror = Mirror(reflecting: value)
922
+ if mirror.displayStyle == .optional {
923
+ guard let wrapped = mirror.children.first?.value else { return NSNull() }
924
+ return jsonSafe(wrapped)
925
+ }
926
+ switch value {
927
+ case let dictionary as [String: Any]:
928
+ return dictionary.mapValues { jsonSafe($0) }
929
+ case let dictionary as NSDictionary:
930
+ var out: [String: Any] = [:]
931
+ for (key, item) in dictionary { out[String(describing: key)] = jsonSafe(item) }
932
+ return out
933
+ case let array as [Any]:
934
+ return array.map { jsonSafe($0) }
935
+ case is String, is NSString, is NSNull:
936
+ return value
937
+ case let flag as Bool:
938
+ return flag
939
+ case let number as NSNumber:
940
+ return number
941
+ case let number as UInt64:
942
+ return NSNumber(value: number)
943
+ case let number as Int64:
944
+ return NSNumber(value: number)
945
+ case let number as UInt32:
946
+ return NSNumber(value: number)
947
+ case let number as Int32:
948
+ return NSNumber(value: number)
949
+ case let number as Double:
950
+ return number.isFinite ? NSNumber(value: number) : NSNull()
951
+ case let date as Date:
952
+ return iso(date)
953
+ case let url as URL:
954
+ return url.absoluteString
955
+ default:
956
+ return String(describing: value)
957
+ }
958
+ }
959
+
896
960
  private func write(_ object: [String: Any], to name: String) {
897
961
  if let data = try? JSONSerialization.data(withJSONObject: object) {
898
962
  try? data.write(to: directory.appendingPathComponent(name), options: .atomic)
@@ -70,6 +70,7 @@ enum CraftNativeActions {
70
70
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
71
71
  // so it needs no web view.
72
72
  if config.enableHeartRate { values.append("heartRate") }
73
+ if config.enableBluetooth { values.append("bluetooth") }
73
74
  // Workouts on the paired Apple Watch (CraftScheduledWorkouts.swift),
74
75
  // also the app's own service.
75
76
  if config.enableScheduledWorkouts { values.append("scheduledWorkouts") }
@@ -401,6 +402,15 @@ enum CraftNativeActions {
401
402
  }
402
403
  CraftWebServices.perform(action: action, body: args.first as? [String: Any] ?? [:]) { complete(requestToken, $0, completion) }
403
404
  return
405
+ case ("Bluetooth", _):
406
+ guard config.enableBluetooth else {
407
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Bluetooth is disabled"), completion)
408
+ return
409
+ }
410
+ CraftBluetooth.shared.perform(method: method, args: args) { result in
411
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
412
+ }
413
+ return
404
414
  case ("HeartRate", _):
405
415
  // A Bluetooth heart-rate strap, the same service as a page's
406
416
  // `craft.heartRate`: one connection and one recording, whichever
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.177",
3
+ "version": "0.0.179",
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>",