@craft-native/ios 0.0.177 → 0.0.180
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 +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +19 -0
- package/templates/CraftBluetooth.swift +243 -0
- package/templates/CraftCrashReporter.swift +72 -8
- package/templates/CraftNativeActions.swift +10 -0
- package/templates/CraftNativeScreen.swift +22 -3
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
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
793
|
-
|
|
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
|
|
@@ -1306,11 +1306,20 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1306
1306
|
|
|
1307
1307
|
override func insertArrangedSubview(_ view: UIView, at stackIndex: Int) {
|
|
1308
1308
|
flowChildren.removeAll { $0 === view }
|
|
1309
|
-
|
|
1309
|
+
let index = min(max(0, stackIndex), flowChildren.count)
|
|
1310
|
+
flowChildren.insert(view, at: index)
|
|
1310
1311
|
if view.superview !== self {
|
|
1311
1312
|
view.removeFromSuperview()
|
|
1312
1313
|
addSubview(view)
|
|
1313
1314
|
}
|
|
1315
|
+
// Stacking follows the tree, as on the web: a later sibling draws and
|
|
1316
|
+
// takes touches above an earlier one. Appending a child inserted
|
|
1317
|
+
// before its siblings put it on top of all of them, so a ScrollView
|
|
1318
|
+
// re-created under a floating button (absolute, after it) took the
|
|
1319
|
+
// button's taps for whatever lay beneath while the button still drew.
|
|
1320
|
+
if let next = flowChildren[(index + 1)...].first(where: { $0.superview === self }) {
|
|
1321
|
+
insertSubview(view, belowSubview: next)
|
|
1322
|
+
}
|
|
1314
1323
|
view.translatesAutoresizingMaskIntoConstraints = true
|
|
1315
1324
|
invalidateMeasurements()
|
|
1316
1325
|
}
|
|
@@ -3963,6 +3972,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3963
3972
|
// it follows light, dark and Increase Contrast by itself.
|
|
3964
3973
|
let surface = Self.floatingSurface(diameter: 44)
|
|
3965
3974
|
button.addSubview(surface)
|
|
3975
|
+
// Pinned to the button rather than resized with it: the button starts
|
|
3976
|
+
// at zero size, so a 44-point surface that grew with its superview
|
|
3977
|
+
// came out 88 points, a rounded card twice the button's size that
|
|
3978
|
+
// spilled off the bar's edge and covered its neighbour.
|
|
3979
|
+
NSLayoutConstraint.activate([
|
|
3980
|
+
surface.leadingAnchor.constraint(equalTo: button.leadingAnchor),
|
|
3981
|
+
surface.trailingAnchor.constraint(equalTo: button.trailingAnchor),
|
|
3982
|
+
surface.topAnchor.constraint(equalTo: button.topAnchor),
|
|
3983
|
+
surface.bottomAnchor.constraint(equalTo: button.bottomAnchor),
|
|
3984
|
+
])
|
|
3966
3985
|
// Glass lifts itself off the content; the material needs the tab
|
|
3967
3986
|
// bar's shadow to.
|
|
3968
3987
|
if surface.effect is UIBlurEffect {
|
|
@@ -4043,8 +4062,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
4043
4062
|
effect = UIBlurEffect(style: .systemUltraThinMaterial)
|
|
4044
4063
|
#endif
|
|
4045
4064
|
let surface = UIVisualEffectView(effect: effect)
|
|
4046
|
-
|
|
4047
|
-
surface.
|
|
4065
|
+
// The caller pins it to its button; it is exactly `diameter` across.
|
|
4066
|
+
surface.translatesAutoresizingMaskIntoConstraints = false
|
|
4048
4067
|
surface.isUserInteractionEnabled = false
|
|
4049
4068
|
surface.layer.cornerRadius = diameter / 2
|
|
4050
4069
|
surface.layer.cornerCurve = .circular
|