craft-native 0.0.176 → 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.
@@ -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.176";
3306
+ var version = "0.0.178";
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.176";
313
+ var version = "0.0.178";
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.176";
61
+ var version = "0.0.178";
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"));
@@ -24,6 +24,11 @@ export type SimulatorStep = {
24
24
  } | {
25
25
  command: 'swipe';
26
26
  direction: 'up' | 'down' | 'left' | 'right';
27
+ }
28
+ /** The system back-swipe: a drag in from the left edge (or the right). */
29
+ | {
30
+ command: 'edge';
31
+ direction: 'left' | 'right';
27
32
  } | {
28
33
  command: 'tabbar';
29
34
  state: 'shown' | 'hidden';
@@ -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
@@ -270,14 +270,24 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
270
270
 
271
271
  @objc private func keyboardTap() {
272
272
  guard keyboardShouldPersistTaps != "always" else { return }
273
- if keyboardShouldPersistTaps == "handled" {
274
- let point = keyboardTapGesture.location(in: self)
275
- var hit = hitTest(point, with: nil)
276
- while let view = hit, view !== self {
277
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
278
- hit = view.superview
279
- }
273
+ let point = keyboardTapGesture.location(in: self)
274
+ var hit = hitTest(point, with: nil)
275
+ var onControl = false
276
+ var onButton = false
277
+ while let view = hit, view !== self {
278
+ if view is UIButton { onButton = true }
279
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
280
+ hit = view.superview
280
281
  }
282
+ if keyboardShouldPersistTaps == "handled", onControl { return }
283
+ // A button keeps the keyboard: its press gives the focus straight
284
+ // back. Ending editing first only let the keyboard's inset go while
285
+ // the finger was down, the button moved out from under it, and the
286
+ // press was lost.
287
+ if onButton { return }
288
+ // Otherwise the keyboard goes; after a tap on nothing, for good (the
289
+ // next render must not bring the last input back).
290
+ if !onControl { craftScreenController?.forgetFocusedInput() }
281
291
  window?.endEditing(true)
282
292
  }
283
293
 
@@ -1166,6 +1166,18 @@ enum CraftNativeFlexLayout {
1166
1166
  /// grid. It is a UIStackView in name only, so existing callers and tests keep
1167
1167
  /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
1168
1168
  /// constraints are never created, and every frame comes from `layout`.
1169
+ extension UIView {
1170
+ /// The native screen this view is drawn on, through the responder chain.
1171
+ var craftScreenController: CraftNativeScreenController? {
1172
+ var responder: UIResponder? = next
1173
+ while let current = responder {
1174
+ if let screen = current as? CraftNativeScreenController { return screen }
1175
+ responder = current.next
1176
+ }
1177
+ return nil
1178
+ }
1179
+ }
1180
+
1169
1181
  private class CraftNativeFlowView: UIStackView {
1170
1182
  var pressActiveOpacity: CGFloat?
1171
1183
  var pressBaseOpacity: CGFloat = 1
@@ -1358,6 +1370,13 @@ private class CraftNativeFlowView: UIStackView {
1358
1370
  craftApplyCornerRadius()
1359
1371
  let scale = window?.screen.scale ?? traitCollection.displayScale
1360
1372
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
1373
+ var moved = false
1374
+ defer {
1375
+ // The screen reports onLayout from its own layout pass, which a
1376
+ // change deep in a scroll view's content never runs: a view that
1377
+ // went from 240 to 180 wide kept saying 240.
1378
+ if moved { craftScreenController?.scheduleLayoutEvents() }
1379
+ }
1361
1380
  for (child, frame) in result.frames {
1362
1381
  var viewportFrame = frame
1363
1382
  if child === viewportChild, viewportChildFillsHeight {
@@ -1372,14 +1391,14 @@ private class CraftNativeFlowView: UIStackView {
1372
1391
  snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
1373
1392
  }
1374
1393
  if child.transform.isIdentity {
1375
- if child.frame != snapped { child.frame = snapped }
1394
+ if child.frame != snapped { child.frame = snapped; moved = true }
1376
1395
  } else {
1377
1396
  // A transformed child (a sticky header, a scaled spinner) is
1378
1397
  // placed by bounds and center: its frame is undefined then.
1379
1398
  let size = CGRect(origin: child.bounds.origin, size: snapped.size)
1380
1399
  let center = CGPoint(x: snapped.midX, y: snapped.midY)
1381
- if child.bounds != size { child.bounds = size }
1382
- if child.center != center { child.center = center }
1400
+ if child.bounds != size { child.bounds = size; moved = true }
1401
+ if child.center != center { child.center = center; moved = true }
1383
1402
  }
1384
1403
  child.craftApplyCornerRadius()
1385
1404
  }
@@ -2192,6 +2211,10 @@ private final class CraftNativeModalView: UIView {
2192
2211
 
2193
2212
  required init?(coder: NSCoder) { nil }
2194
2213
 
2214
+ /// Whether the modal takes touches is its own to say: shown, it takes
2215
+ /// every one (the scrim and its controls); the screen's press and long
2216
+ /// press bookkeeping once turned that off for a view with no handler of
2217
+ /// its own, and a shown modal let every tap through to the screen behind.
2195
2218
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
2196
2219
  if visible { isUserInteractionEnabled = true }
2197
2220
  if renderedVisible == visible && isHidden == !visible {
@@ -2273,7 +2296,27 @@ private final class CraftNativeModalView: UIView {
2273
2296
  override func layoutSubviews() {
2274
2297
  super.layoutSubviews()
2275
2298
  blocker.frame = bounds
2276
- contentStack.frame = bounds
2299
+ // The scrim covers the whole screen; what the modal holds stays
2300
+ // clear of the bars and the home indicator it is drawn over.
2301
+ contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
2302
+ }
2303
+
2304
+ /// Where the screen's own content starts and ends: the scrim covers the
2305
+ /// whole screen, what the modal holds stays clear of the bars.
2306
+ var contentInsets: UIEdgeInsets = .zero {
2307
+ didSet { if contentInsets != oldValue { setNeedsLayout() } }
2308
+ }
2309
+ }
2310
+
2311
+ /// The delegate of a Pressable's press-in/out feedback. That recognizer is
2312
+ /// a long press of no duration, so it begins the moment a finger lands; on its
2313
+ /// own it would claim the touch, and the tap that is the Pressable's onPress
2314
+ /// failed whenever onPressIn or onPressOut were set too. It only watches.
2315
+ final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
2316
+ static let shared = CraftPressFeedbackDelegate()
2317
+
2318
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2319
+ true
2277
2320
  }
2278
2321
  }
2279
2322
 
@@ -2545,14 +2588,24 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
2545
2588
 
2546
2589
  @objc private func keyboardTap() {
2547
2590
  guard keyboardShouldPersistTaps != "always" else { return }
2548
- if keyboardShouldPersistTaps == "handled" {
2549
- let point = keyboardTapGesture.location(in: self)
2550
- var hit = hitTest(point, with: nil)
2551
- while let view = hit, view !== self {
2552
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
2553
- hit = view.superview
2554
- }
2555
- }
2591
+ let point = keyboardTapGesture.location(in: self)
2592
+ var hit = hitTest(point, with: nil)
2593
+ var onControl = false
2594
+ var onButton = false
2595
+ while let view = hit, view !== self {
2596
+ if view is UIButton { onButton = true }
2597
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
2598
+ hit = view.superview
2599
+ }
2600
+ if keyboardShouldPersistTaps == "handled", onControl { return }
2601
+ // A button keeps the keyboard: its press gives the focus straight
2602
+ // back. Ending editing first only let the keyboard's inset go while
2603
+ // the finger was down, the button moved out from under it, and the
2604
+ // press was lost.
2605
+ if onButton { return }
2606
+ // Otherwise the keyboard goes; after a tap on nothing, for good (the
2607
+ // next render must not bring the last input back).
2608
+ if !onControl { craftScreenController?.forgetFocusedInput() }
2556
2609
  window?.endEditing(true)
2557
2610
  }
2558
2611
 
@@ -3056,6 +3109,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3056
3109
  private var linkTargets: [ObjectIdentifier: String] = [:]
3057
3110
  private var renderedRoot: RenderedNode?
3058
3111
  private weak var lastFocusedInput: UIView?
3112
+
3113
+ /// The person put the keyboard away (Return, or a tap on nothing in a
3114
+ /// scroll view): the last input is let go. Kept, the next render or
3115
+ /// button press focused it again and the keyboard came straight back.
3116
+ func forgetFocusedInput(_ input: UIView? = nil) {
3117
+ if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
3118
+ }
3059
3119
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
3060
3120
  private var flatListOwners: [String: String] = [:]
3061
3121
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
@@ -3192,6 +3252,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3192
3252
  if !isAppScript, let routeName, !routeName.isEmpty {
3193
3253
  CraftCrashReporter.shared.screenAppeared(routeName)
3194
3254
  }
3255
+ // onLayout reports views that are on screen: a pushed screen lays
3256
+ // out before it has a window, and nothing moved again once it had
3257
+ // one, so its rows never reported.
3258
+ emitLayoutEvents()
3195
3259
  }
3196
3260
 
3197
3261
  override func viewDidDisappear(_ animated: Bool) {
@@ -3266,9 +3330,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3266
3330
 
3267
3331
  override func viewDidLayoutSubviews() {
3268
3332
  super.viewDidLayoutSubviews()
3333
+ positionModals()
3269
3334
  emitLayoutEvents()
3270
3335
  }
3271
3336
 
3337
+ /// Modals cover the screen; their content starts below its bars (the
3338
+ /// status bar is all the safe area counts on a native screen, and a
3339
+ /// modal's first control sat under the navigation bar).
3340
+ private func positionModals() {
3341
+ let modals = view.subviews.compactMap { $0 as? CraftNativeModalView }
3342
+ guard !modals.isEmpty else { return }
3343
+ var insets = view.safeAreaInsets
3344
+ // The navigation bar floats over the screen without counting in its
3345
+ // safe area, and it is drawn above anything the screen adds.
3346
+ if let nav = navigationController, !nav.isNavigationBarHidden, nav.navigationBar.window != nil {
3347
+ let bar = nav.navigationBar.convert(nav.navigationBar.bounds, to: view)
3348
+ if bar.intersects(view.bounds) { insets.top = max(insets.top, bar.maxY) }
3349
+ }
3350
+ for modal in modals {
3351
+ if modal.frame != view.bounds { modal.frame = view.bounds }
3352
+ modal.contentInsets = insets
3353
+ }
3354
+ }
3355
+
3272
3356
  private static func safeJavaScriptString(_ value: JSValue?) -> String? {
3273
3357
  guard let value, !value.isUndefined, !value.isNull, value.isString else { return nil }
3274
3358
  return value.toString()
@@ -4469,6 +4553,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4469
4553
  guard let target = drawnTarget(id) else { return false }
4470
4554
  guard applied.insert(target).inserted else { continue }
4471
4555
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
4556
+ // A node that needs a new view (a TextInput turned multiline) is
4557
+ // its parent's to swap in: a targeted update only redraws in
4558
+ // place, and the new view was made and never attached.
4559
+ guard canReuseView(document, previous: previous) else { return false }
4472
4560
  let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
4473
4561
  // A style patch is applied in place, layout included: the parent
4474
4562
  // reads the node's new layout style on its next pass.
@@ -4564,6 +4652,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4564
4652
  return nil
4565
4653
  }
4566
4654
 
4655
+ /// Whether `previous`'s view can draw `node`: the same type, and for a
4656
+ /// TextInput the same kind of field (multiline is a UITextView).
4657
+ private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
4658
+ let type = canonicalType(node["type"] as? String ?? "View")
4659
+ guard previous.type == type else { return false }
4660
+ guard type == "TextInput" else { return true }
4661
+ let props = node["props"] as? [String: Any] ?? [:]
4662
+ return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
4663
+ }
4664
+
4567
4665
  private func restoreFocus(_ focused: UIView?) {
4568
4666
  guard let focused, focused.window != nil, !focused.isFirstResponder else { return }
4569
4667
  _ = focused.becomeFirstResponder()
@@ -4576,9 +4674,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4576
4674
  let events = node["events"] as? [String: String] ?? [:]
4577
4675
  let children = node["children"] as? [Any] ?? []
4578
4676
  let current: RenderedNode
4579
- let wantsMultilineTextView = type == "TextInput" && props["multiline"] as? Bool == true
4580
- let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
4581
- if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
4677
+ if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
4582
4678
  current = previous
4583
4679
  } else {
4584
4680
  current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
@@ -4796,6 +4892,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4796
4892
  let modalID = ObjectIdentifier(modal)
4797
4893
  let wasVisible = modalVisibility[modalID]
4798
4894
  modalVisibility[modalID] = visible
4895
+ if visible, wasVisible != true { positionModals() }
4799
4896
  modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
4800
4897
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
4801
4898
  { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
@@ -5199,14 +5296,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5199
5296
  }
5200
5297
  for child in next where child.type == "Modal" {
5201
5298
  guard let modal = child.view as? CraftNativeModalView else { continue }
5202
- if modal.superview !== stack {
5299
+ // A modal covers the screen, wherever it is declared. Laid over
5300
+ // its parent instead, one declared in a scroll view covered the
5301
+ // whole content: its controls sat wherever the content had
5302
+ // scrolled to, under the bar or off screen, out of reach.
5303
+ if modal.superview !== view {
5203
5304
  modal.removeFromSuperview()
5204
- stack.addSubview(modal)
5305
+ view.addSubview(modal)
5205
5306
  }
5206
- modal.frame = stack.bounds
5207
5307
  modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
5208
- stack.bringSubviewToFront(modal)
5308
+ view.bringSubviewToFront(modal)
5209
5309
  }
5310
+ positionModals()
5210
5311
  parent.children = next
5211
5312
  addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
5212
5313
  }
@@ -5326,7 +5427,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5326
5427
  }
5327
5428
  if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
5328
5429
  let id = ObjectIdentifier(node.view)
5329
- if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
5430
+ if let modal = node.view as? CraftNativeModalView {
5431
+ modal.resetForTeardown()
5432
+ // It sits on the screen, not in its parent: leaving with the
5433
+ // parent's subtree does not take it away.
5434
+ modal.removeFromSuperview()
5435
+ }
5330
5436
  handlers.removeValue(forKey: id)
5331
5437
  linkTargets.removeValue(forKey: id)
5332
5438
  pressInHandlers.removeValue(forKey: id)
@@ -5401,6 +5507,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5401
5507
  emitLayoutEvents(for: renderedRoot)
5402
5508
  }
5403
5509
 
5510
+ private var layoutEventsScheduled = false
5511
+
5512
+ /// A view inside the screen moved: report onLayout once the pass that
5513
+ /// moved it is over, however deep it was.
5514
+ fileprivate func scheduleLayoutEvents() {
5515
+ guard !layoutEventsScheduled, !layoutHandlers.isEmpty else { return }
5516
+ layoutEventsScheduled = true
5517
+ DispatchQueue.main.async { [weak self] in
5518
+ guard let self else { return }
5519
+ self.layoutEventsScheduled = false
5520
+ self.emitLayoutEvents()
5521
+ }
5522
+ }
5523
+
5404
5524
  private func emitLayoutEvents(for node: RenderedNode) {
5405
5525
  let id = ObjectIdentifier(node.view)
5406
5526
  if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
@@ -5463,7 +5583,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5463
5583
  }
5464
5584
  // A container keeps receiving touches without a handler of its own,
5465
5585
  // or every control inside it (and the screen root) would be dead.
5466
- if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5586
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5467
5587
  }
5468
5588
 
5469
5589
  private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
@@ -5472,7 +5592,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5472
5592
  if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
5473
5593
  guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
5474
5594
  if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
5475
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5595
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5476
5596
  return
5477
5597
  }
5478
5598
  if pressFeedbackRecognizers[id] == nil {
@@ -5480,17 +5600,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5480
5600
  recognizer.minimumPressDuration = 0
5481
5601
  recognizer.allowableMovement = 10
5482
5602
  recognizer.cancelsTouchesInView = false
5603
+ recognizer.delegate = CraftPressFeedbackDelegate.shared
5483
5604
  pressFeedbackRecognizers[id] = recognizer
5484
5605
  view.addGestureRecognizer(recognizer)
5485
5606
  }
5486
- if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
5607
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
5487
5608
  }
5488
5609
 
5489
5610
  private func updateLongPressHandler(_ handler: String?, for view: UIView) {
5490
5611
  let id = ObjectIdentifier(view)
5491
5612
  if let handler, !handler.isEmpty {
5492
5613
  longPressHandlers[id] = handler
5493
- if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
5614
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
5494
5615
  if longPressRecognizers[id] == nil {
5495
5616
  let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
5496
5617
  longPressRecognizers[id] = recognizer
@@ -5501,7 +5622,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5501
5622
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
5502
5623
  view.removeGestureRecognizer(recognizer)
5503
5624
  }
5504
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
5625
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
5505
5626
  }
5506
5627
  }
5507
5628
 
@@ -6116,6 +6237,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6116
6237
  if state["checked"] as? Bool == true { traits.insert(.selected) }
6117
6238
  }
6118
6239
  if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
6240
+ // A disabled Pressable or touchable takes no touches; it says so too.
6241
+ if props["disabled"] as? Bool == true { traits.insert(.notEnabled) }
6119
6242
  if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
6120
6243
  if checkbox.isSelected { traits.insert(.selected) }
6121
6244
  view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
@@ -6266,13 +6389,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6266
6389
  let value = snappedSliderValue(sender.value, for: sender)
6267
6390
  if sender.value != value { sender.value = value }
6268
6391
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
6269
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
6392
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value, step: sliderSteps[ObjectIdentifier(sender)])]])
6270
6393
  }
6271
6394
 
6272
- /// A slider's value as the script reads it: the shortest decimal that is
6273
- /// the same Float, so a step of 0.1 gives 0.8 and not 0.800000011920929.
6274
- static func sliderEventValue(_ value: Float) -> Double {
6275
- Double(value.description) ?? Double(value)
6395
+ /// A slider's value as the script reads it. UISlider keeps a Float, and a
6396
+ /// step of 0.1 counted up in Floats lands beside the decimal (0.8 read as
6397
+ /// 0.800000011920929, nine steps as 0.90000004), so the value is the
6398
+ /// shortest decimal for that Float, rounded to as many places as the step
6399
+ /// has.
6400
+ static func sliderEventValue(_ value: Float, step: Float? = nil) -> Double {
6401
+ let decimal = Double(value.description) ?? Double(value)
6402
+ guard let step, step > 0, step.isFinite else { return decimal }
6403
+ // "0.1" is one place, "0.25" two, "1.0" none; a step as small as
6404
+ // "1e-07" is held to six.
6405
+ let fraction = step.description.split(separator: ".").dropFirst().first
6406
+ let places = step.description.contains("e") ? 6 : min(6, fraction.map { $0 == "0" ? 0 : $0.count } ?? 0)
6407
+ let scale = pow(10, Double(places))
6408
+ return (decimal * scale).rounded() / scale
6276
6409
  }
6277
6410
 
6278
6411
  /// `@change` on a DatePicker: the value as `<input type="date|time|
@@ -6378,7 +6511,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6378
6511
 
6379
6512
  @objc private func sliderFinished(_ sender: UISlider) {
6380
6513
  guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
6381
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
6514
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
6382
6515
  }
6383
6516
 
6384
6517
  func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
@@ -6423,6 +6556,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6423
6556
  }
6424
6557
 
6425
6558
  @objc private func textSubmitted(_ sender: UITextField) {
6559
+ // Return put the keyboard away.
6560
+ forgetFocusedInput(sender)
6426
6561
  guard let handler = submitHandlers[ObjectIdentifier(sender)] else { return }
6427
6562
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
6428
6563
  }
@@ -286,6 +286,14 @@ final class DriverTests: XCTestCase {
286
286
  case "left": app.swipeLeft()
287
287
  default: app.swipeRight()
288
288
  }
289
+ case "edge":
290
+ // The system back-swipe: a drag in from the left edge (or the
291
+ // right one with `edge right`), which a centred swipe never is.
292
+ let fromRight = arg == "right"
293
+ let start = app.coordinate(withNormalizedOffset: CGVector(dx: fromRight ? 0.995 : 0.005, dy: 0.5))
294
+ let end = app.coordinate(withNormalizedOffset: CGVector(dx: fromRight ? 0.15 : 0.85, dy: 0.5))
295
+ start.press(forDuration: 0.05, thenDragTo: end)
296
+ note("edge \(fromRight ? "right" : "left"): ok")
289
297
  case "audit":
290
298
  var issues: [String] = []
291
299
  do {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.176",
3
+ "version": "0.0.178",
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>",