@craft-native/ios 0.0.177 → 0.0.178

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/dist/cli.js CHANGED
@@ -871,6 +871,7 @@ function writeShell(output, config) {
871
871
  cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
872
872
  cpSync(join3(TEMPLATES_DIR, "CraftHealth.swift"), join3(output, "Sources", "CraftHealth.swift"));
873
873
  cpSync(join3(TEMPLATES_DIR, "CraftMusic.swift"), join3(output, "Sources", "CraftMusic.swift"));
874
+ cpSync(join3(TEMPLATES_DIR, "CraftBluetooth.swift"), join3(output, "Sources", "CraftBluetooth.swift"));
874
875
  cpSync(join3(TEMPLATES_DIR, "CraftHeartRate.swift"), join3(output, "Sources", "CraftHeartRate.swift"));
875
876
  cpSync(join3(TEMPLATES_DIR, "CraftScheduledWorkouts.swift"), join3(output, "Sources", "CraftScheduledWorkouts.swift"));
876
877
  cpSync(join3(TEMPLATES_DIR, "CraftNativeMutation.swift"), join3(output, "Sources", "CraftNativeMutation.swift"));
package/dist/index.js CHANGED
@@ -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"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.177",
3
+ "version": "0.0.178",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
+ }
@@ -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