craft-native 0.0.424 → 0.0.435

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.
@@ -478,7 +478,7 @@ internal class CraftNativeCapabilities(
478
478
  }
479
479
  "remove" -> {
480
480
  val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
481
- success(file.delete())
481
+ success(removeOwnedFile(file))
482
482
  }
483
483
  "download" -> download(requestToken, args)
484
484
  else -> failure("UNKNOWN_ACTION", "Unsupported Files method")
@@ -802,7 +802,7 @@ internal class CraftNativeCapabilities(
802
802
  success(copied)
803
803
  } catch (error: Exception) {
804
804
  for (index in 0 until copied.length()) {
805
- copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.parentFile?.deleteRecursively() }
805
+ copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.let(::removeOwnedFile) }
806
806
  }
807
807
  val message = error.message ?: "The selected document could not be read"
808
808
  if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
@@ -915,6 +915,18 @@ internal class CraftNativeCapabilities(
915
915
  return file.takeIf { it.path.startsWith(root.path + File.separator) }
916
916
  }
917
917
 
918
+ /** Delete only a validated owned file; never sweep siblings or the shared root. */
919
+ private fun removeOwnedFile(file: File): Boolean {
920
+ // File.delete is non-recursive and reports permission failures. Reject
921
+ // directories before removal; parent pruning must remain best effort.
922
+ if (!runCatching { file.isFile && file.delete() }.getOrDefault(false)) return false
923
+ runCatching {
924
+ val parent = file.parentFile
925
+ if (parent != null && parent != fileRoot.canonicalFile && parent.isDirectory) parent.delete()
926
+ }
927
+ return true
928
+ }
929
+
918
930
  private fun safeFileName(raw: String): String {
919
931
  val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
920
932
  return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
@@ -939,7 +951,7 @@ internal class CraftNativeCapabilities(
939
951
  val files = if (data is JSONArray) (0 until data.length()).mapNotNull { data.optJSONObject(it) }
940
952
  else listOfNotNull(data as? JSONObject)
941
953
  files.forEach { file ->
942
- ownedFile(file.optString("path"))?.parentFile?.deleteRecursively()
954
+ ownedFile(file.optString("path"))?.let(::removeOwnedFile)
943
955
  }
944
956
  }
945
957
 
@@ -57,13 +57,14 @@ internal class CraftNativeSounds(
57
57
  if (id.isBlank()) return done(failure("INVALID_ARGUMENT", "audio.play needs an id"))
58
58
  val uri = source(address)
59
59
  ?: return done(failure("INVALID_ARGUMENT", "audio.play needs an absolute http, https, file or content URL"))
60
- stop(id, 0.0)
61
-
62
60
  val requestedVolume = options.optDouble("volume", 1.0)
63
61
  val volume = requestedVolume.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0)?.toFloat()
64
62
  ?: return done(failure("INVALID_ARGUMENT", "audio.play volume must be a finite number between 0 and 1"))
65
63
  val fadeIn = options.optDouble("fadeIn", 0.0)
66
64
  if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
65
+ // Reject malformed options before cancelling a sound with the same id.
66
+ // Its player, pending load and cancellation handle still belong to it.
67
+ stop(id, 0.0)
67
68
  val player = MediaPlayer()
68
69
  val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
69
70
  sounds[id] = sound
@@ -9,6 +9,10 @@ import android.content.pm.PackageManager
9
9
  import android.content.res.ColorStateList
10
10
  import android.content.res.Configuration
11
11
  import android.content.res.Resources
12
+ import android.hardware.Sensor
13
+ import android.hardware.SensorEvent
14
+ import android.hardware.SensorEventListener
15
+ import android.hardware.SensorManager
12
16
  import android.database.ContentObserver
13
17
  import android.content.Intent
14
18
  import android.app.PictureInPictureParams
@@ -4683,6 +4687,7 @@ class MainActivity : AppCompatActivity() {
4683
4687
  private val capabilityHandler = Handler(Looper.getMainLooper())
4684
4688
  private var hapticsEnabled = false
4685
4689
  private var clipboardEnabled = false
4690
+ private var shakeEnabled = false
4686
4691
  internal var nativeAppState = "inactive"
4687
4692
  private set
4688
4693
  private var deliveredInitialDeepLink = false
@@ -4755,6 +4760,7 @@ class MainActivity : AppCompatActivity() {
4755
4760
  }.getOrDefault(JSONObject())
4756
4761
  hapticsEnabled = config.optBoolean("enableHaptics", false)
4757
4762
  clipboardEnabled = config.optBoolean("enableClipboard", false)
4763
+ shakeEnabled = config.optBoolean("enableShakeGesture", false)
4758
4764
  // Normally started already by its content provider; this covers a
4759
4765
  // process that skipped providers (instrumentation).
4760
4766
  CraftCrashReporter.start(this)
@@ -6101,9 +6107,7 @@ class MainActivity : AppCompatActivity() {
6101
6107
  val handler = craftNativeEventName(events, "onPress", "onClick")
6102
6108
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
6103
6109
  setOnClickListener {
6104
- screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
6105
- val current = screen.handlers[this] ?: return@setOnClickListener
6106
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6110
+ dispatchNativePress(screen, it)
6107
6111
  }
6108
6112
  }
6109
6113
  "Button" -> (previous as? Button ?: CraftNativePointerButton(this).apply {
@@ -6877,9 +6881,7 @@ class MainActivity : AppCompatActivity() {
6877
6881
  control.isClickable = true
6878
6882
  if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
6879
6883
  control.setOnClickListener {
6880
- screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
6881
- val current = screen.handlers[it] ?: return@setOnClickListener
6882
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6884
+ dispatchNativePress(screen, it)
6883
6885
  }
6884
6886
  control.setOnTouchListener { view, event ->
6885
6887
  if (!CraftNativePointers.ownsTouch(view) && event.actionMasked != MotionEvent.ACTION_CANCEL) return@setOnTouchListener false
@@ -6907,9 +6909,7 @@ class MainActivity : AppCompatActivity() {
6907
6909
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6908
6910
  }
6909
6911
  if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
6910
- screen.linkTargets[view]?.let { target -> navigateOpen(target); return@setOnTouchListener true }
6911
- val current = screen.handlers[view] ?: return@setOnTouchListener true
6912
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6912
+ dispatchNativePress(screen, view)
6913
6913
  }
6914
6914
  true
6915
6915
  }
@@ -6920,9 +6920,7 @@ class MainActivity : AppCompatActivity() {
6920
6920
  control.setOnTouchListener(null)
6921
6921
  if (screen.pressBaseBackgrounds.containsKey(control)) control.background = screen.pressBaseBackgrounds[control]
6922
6922
  control.setOnClickListener {
6923
- screen.linkTargets[control]?.let { target -> navigateOpen(target); return@setOnClickListener }
6924
- val current = screen.handlers[control] ?: return@setOnClickListener
6925
- send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
6923
+ dispatchNativePress(screen, control)
6926
6924
  }
6927
6925
  }
6928
6926
  } else {
@@ -8254,7 +8252,45 @@ class MainActivity : AppCompatActivity() {
8254
8252
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
8255
8253
  }
8256
8254
 
8255
+ // MARK: - Shake gesture (enableShakeGesture)
8256
+
8257
+ /// The accelerometer, watched for a shake while the app is in front, as on
8258
+ /// iOS. Armed on resume and disarmed on pause, because the sensor is the
8259
+ /// whole cost of this feature and nobody can shake a phone at an app they
8260
+ /// cannot see.
8261
+ private var lastShakeAt = 0L
8262
+ private val shakeSensors by lazy { getSystemService(Context.SENSOR_SERVICE) as? SensorManager }
8263
+ private val shakeListener = object : SensorEventListener {
8264
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
8265
+ override fun onSensorChanged(event: SensorEvent?) {
8266
+ val reading = event?.values ?: return
8267
+ if (reading.size < 3) return
8268
+ // Android reports m/s^2 with gravity included, so a phone at rest
8269
+ // reads about 9.81. Converted to g to match the iOS threshold and
8270
+ // the force the event carries.
8271
+ val magnitude = kotlin.math.sqrt((reading[0] * reading[0] + reading[1] * reading[1] + reading[2] * reading[2]).toDouble()) / SensorManager.GRAVITY_EARTH
8272
+ if (magnitude < 2.2) return
8273
+ val now = System.currentTimeMillis()
8274
+ // One event per gesture, not one per sample.
8275
+ if (now - lastShakeAt < 2000L) return
8276
+ lastShakeAt = now
8277
+ CraftNativeHub.broadcast("CRAFT_EVENT", JSONObject().put("event", "shake").put("detail", JSONObject().put("force", magnitude)))
8278
+ }
8279
+ }
8280
+
8281
+ private fun armShakeGesture() {
8282
+ if (!shakeEnabled) return
8283
+ val sensor = shakeSensors?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) ?: return
8284
+ shakeSensors?.registerListener(shakeListener, sensor, SensorManager.SENSOR_DELAY_UI)
8285
+ }
8286
+
8287
+ private fun disarmShakeGesture() {
8288
+ if (!shakeEnabled) return
8289
+ shakeSensors?.unregisterListener(shakeListener)
8290
+ }
8291
+
8257
8292
  override fun onPause() {
8293
+ disarmShakeGesture()
8258
8294
  if (::services.isInitialized) services.pauseVoice()
8259
8295
  nativeAppState = "inactive"
8260
8296
  publish("APP_STATE", JSONObject().put("state", nativeAppState))
@@ -8314,6 +8350,7 @@ class MainActivity : AppCompatActivity() {
8314
8350
 
8315
8351
  override fun onResume() {
8316
8352
  super.onResume()
8353
+ armShakeGesture()
8317
8354
  if (::services.isInitialized) services.resumed()
8318
8355
  }
8319
8356
 
@@ -8371,6 +8408,18 @@ class MainActivity : AppCompatActivity() {
8371
8408
  }
8372
8409
  }
8373
8410
 
8411
+ // The authored handler owns navigation. The bridge is asynchronous, so
8412
+ // opening the link as well cannot honor a handler's preventDefault intent.
8413
+ private fun dispatchNativePress(screen: Screen, view: View) {
8414
+ if (!view.isEnabled) return
8415
+ val handler = screen.handlers[view]
8416
+ if (handler != null) {
8417
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject()))
8418
+ } else {
8419
+ screen.linkTargets[view]?.let { target -> navigateOpen(target) }
8420
+ }
8421
+ }
8422
+
8374
8423
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
8375
8424
  if (!screen.active) return
8376
8425
  val message = JSONObject().put("type", type).put("payload", payload)
package/dist/browser.js CHANGED
@@ -317,7 +317,7 @@ async function createBrowserStorage(options) {
317
317
  };
318
318
  const accountChanged = async () => {
319
319
  const next = storage.getItem(tokenKey);
320
- if (next === token) {
320
+ if (next === token && (!transitioning || transition)) {
321
321
  await transition;
322
322
  return;
323
323
  }
@@ -351,7 +351,14 @@ async function createBrowserStorage(options) {
351
351
  if (before !== next)
352
352
  options.sessionChanged?.(next, before);
353
353
  })();
354
- await transition;
354
+ const attempt = transition;
355
+ try {
356
+ await attempt;
357
+ } catch (error) {
358
+ if (current === revision && transition === attempt)
359
+ transition = null;
360
+ throw error;
361
+ }
355
362
  };
356
363
  const secureKey = (name) => name === sessionTokenKey ? tokenKey : key(`credential:${name}`);
357
364
  return {
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.424";
3050
+ var version = "0.0.435";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.424";
378
+ var version = "0.0.435";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.424";
120
+ var version = "0.0.435";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -84,6 +84,8 @@ export interface CraftConfig {
84
84
  enableAudioRecording?: boolean;
85
85
  enableVideoRecording?: boolean;
86
86
  enableMotionSensors?: boolean;
87
+ /** Shaking the phone raises a `shake` event. Arms the accelerometer while the app is in front. */
88
+ enableShakeGesture?: boolean;
87
89
  enableLocalDatabase?: boolean;
88
90
  enableBluetooth?: boolean;
89
91
  enableNFC?: boolean;
@@ -18,7 +18,7 @@ function iosPermissions(config) {
18
18
  contacts: config.enableContacts,
19
19
  calendar: config.enableCalendar,
20
20
  bluetooth: config.enableBluetooth || config.enableHeartRate,
21
- motion: config.enableMotionSensors,
21
+ motion: config.enableMotionSensors || config.enableShakeGesture,
22
22
  biometric: config.enableBiometric,
23
23
  music: config.enableAppleMusic,
24
24
  health: config.enableHealthKit,
@@ -701,6 +701,7 @@ var DEFAULT_CONFIG = {
701
701
  enableAudioRecording: false,
702
702
  enableVideoRecording: false,
703
703
  enableMotionSensors: false,
704
+ enableShakeGesture: false,
704
705
  enableLocalDatabase: false,
705
706
  enableBluetooth: false,
706
707
  enableNFC: false,
@@ -1830,6 +1830,11 @@ struct CraftConfig: Codable {
1830
1830
  var enableAudioRecording: Bool = false
1831
1831
  var enableVideoRecording: Bool = false
1832
1832
  var enableMotionSensors: Bool = false
1833
+ /// Shaking the phone raises a `shake` event, for a report-a-problem
1834
+ /// gesture. Its own flag rather than a corner of `enableMotionSensors`,
1835
+ /// because it arms the accelerometer for as long as the app is in front:
1836
+ /// an app that wanted only the motion API should not pay for that.
1837
+ var enableShakeGesture: Bool = false
1833
1838
  var enableLocalDatabase: Bool = false
1834
1839
  var enableBluetooth: Bool = false
1835
1840
  var enableNFC: Bool = false
@@ -2413,6 +2418,10 @@ struct CraftWebView: UIViewRepresentable {
2413
2418
  private var motionManager: CMMotionManager?
2414
2419
  private var isMotionUpdating = false
2415
2420
 
2421
+ // Shake gesture (enableShakeGesture), armed only while the app is in front
2422
+ private var shakeMotion: CMMotionManager?
2423
+ private var lastShakeAt: Date?
2424
+
2416
2425
  // Bluetooth
2417
2426
  private var centralManager: CBCentralManager?
2418
2427
  private var peripheralManager: CBPeripheralManager?
@@ -2465,6 +2474,8 @@ struct CraftWebView: UIViewRepresentable {
2465
2474
  if config.enableMotionSensors {
2466
2475
  motionManager = CMMotionManager()
2467
2476
  }
2477
+ // The app starts in front, so the gesture starts armed.
2478
+ armShakeGesture()
2468
2479
  #endif
2469
2480
  if config.enableHealthKit {
2470
2481
  // Apple Health's heart rate as it arrives
@@ -3866,6 +3877,7 @@ struct CraftWebView: UIViewRepresentable {
3866
3877
  @objc private func appDidEnterBackground(_ notification: Notification) {
3867
3878
  backgroundedAt = Date()
3868
3879
  saveInteractionState()
3880
+ disarmShakeGesture()
3869
3881
  }
3870
3882
 
3871
3883
  @objc private func appDidReceiveMemoryWarning(_ notification: Notification) {
@@ -3997,6 +4009,7 @@ struct CraftWebView: UIViewRepresentable {
3997
4009
  }
3998
4010
 
3999
4011
  @objc private func appWillEnterForeground(_ notification: Notification) {
4012
+ armShakeGesture()
4000
4013
  if recoverOnForeground, let webView {
4001
4014
  // The page is about to be rebuilt; there is no one to resume.
4002
4015
  backgroundedAt = nil
@@ -8499,6 +8512,56 @@ struct CraftWebView: UIViewRepresentable {
8499
8512
  #endif
8500
8513
  }
8501
8514
 
8515
+ // MARK: - Shake Gesture
8516
+
8517
+ /// The accelerometer, watched for a shake while the app is in front.
8518
+ ///
8519
+ /// Deliberately not `startDeviceMotionUpdates`: that fuses attitude and
8520
+ /// gravity every sample, and a shake needs neither. Raw acceleration at
8521
+ /// 20 Hz is enough to see one and costs a fraction of the same watch.
8522
+ ///
8523
+ /// Armed on foreground and disarmed on background, because the sensor
8524
+ /// is the whole cost of this feature and nobody can shake a phone at an
8525
+ /// app they cannot see.
8526
+ private func armShakeGesture() {
8527
+ #if CRAFT_PERMISSION_MOTION
8528
+ guard config.enableShakeGesture, shakeMotion == nil else { return }
8529
+ let manager = CMMotionManager()
8530
+ guard manager.isAccelerometerAvailable else { return }
8531
+ manager.accelerometerUpdateInterval = 1.0 / 20.0
8532
+ shakeMotion = manager
8533
+ manager.startAccelerometerUpdates(to: .main) { [weak self] sample, _ in
8534
+ guard let self, let sample else { return }
8535
+ self.readShake(sample.acceleration)
8536
+ }
8537
+ #endif
8538
+ }
8539
+
8540
+ private func disarmShakeGesture() {
8541
+ #if CRAFT_PERMISSION_MOTION
8542
+ shakeMotion?.stopAccelerometerUpdates()
8543
+ shakeMotion = nil
8544
+ #endif
8545
+ }
8546
+
8547
+ #if CRAFT_PERMISSION_MOTION
8548
+ private func readShake(_ force: CMAcceleration) {
8549
+ // Total force, gravity included, so a phone at rest reads 1g and
8550
+ // the threshold is what a deliberate shake adds on top of it. At
8551
+ // 2.2g a pocket, a footfall and setting the phone down are all
8552
+ // below it, which is what a fitness app has to clear.
8553
+ let magnitude = sqrt(force.x * force.x + force.y * force.y + force.z * force.z)
8554
+ guard magnitude >= 2.2 else { return }
8555
+ let now = Date()
8556
+ // One event per gesture, not one per sample. A shake is a second
8557
+ // or two of samples over the threshold, and every one of them
8558
+ // would otherwise be reported as its own shake.
8559
+ if let lastShakeAt, now.timeIntervalSince(lastShakeAt) < 2.0 { return }
8560
+ lastShakeAt = now
8561
+ sendToWeb("shake", data: ["force": magnitude])
8562
+ }
8563
+ #endif
8564
+
8502
8565
  // MARK: - Motion Sensors
8503
8566
  private func startMotionUpdates(interval: Double, callbackId: String?) {
8504
8567
  #if CRAFT_PERMISSION_MOTION
@@ -2716,6 +2716,8 @@ enum CraftNativeFlexLayout {
2716
2716
  ? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
2717
2717
  : maxContent.height)
2718
2718
  return CGSize(width: resolvedWidth, height: resolvedHeight)
2719
+ case let image as CraftNativePositionedImageView where image.image == nil:
2720
+ return CGSize(width: width ?? 0, height: height ?? 0)
2719
2721
  case let image as UIImageView where image.image == nil:
2720
2722
  return CGSize(width: width ?? 0, height: height ?? 0)
2721
2723
  default:
@@ -4165,25 +4167,37 @@ private extension UIImage {
4165
4167
  }
4166
4168
 
4167
4169
  /// Explicit CSS-facing image geometry; the loader and semantic view stay retained.
4168
- class CraftNativePositionedImageView: UIImageView {
4170
+ class CraftNativePositionedImageView: UIView {
4169
4171
  private var storedImage: UIImage?
4170
- private var fittedImage: UIImageView?
4172
+ private let fittedImage = UIImageView()
4171
4173
  private var fit: String?
4172
4174
  private var position = CGPoint(x: 0.5, y: 0.5)
4173
4175
 
4174
- override var image: UIImage? {
4176
+ override init(frame: CGRect) {
4177
+ super.init(frame: frame)
4178
+ isUserInteractionEnabled = false
4179
+ fittedImage.isUserInteractionEnabled = false
4180
+ fittedImage.isAccessibilityElement = false
4181
+ addSubview(fittedImage)
4182
+ }
4183
+
4184
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
4185
+
4186
+ // Keep source identity separate from UIKit's image renderer. A UIImageView
4187
+ // host with this stored-image getter painted an extra copy even with
4188
+ // super.image set to nil; only the child owns image drawing now.
4189
+ var image: UIImage? {
4175
4190
  get { storedImage }
4176
4191
  set {
4177
4192
  storedImage = newValue
4178
- super.image = fit == nil ? newValue : nil
4179
- fittedImage?.image = newValue
4193
+ fittedImage.image = newValue
4180
4194
  invalidateIntrinsicContentSize()
4181
4195
  setNeedsLayout()
4182
4196
  }
4183
4197
  }
4184
4198
 
4185
4199
  override var intrinsicContentSize: CGSize {
4186
- fit == nil ? super.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
4200
+ fit == nil ? fittedImage.intrinsicContentSize : storedImage?.craftIntrinsicSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
4187
4201
  }
4188
4202
 
4189
4203
  func configureObjectGeometry(props: [String: Any], style: [String: Any]) {
@@ -4197,22 +4211,28 @@ class CraftNativePositionedImageView: UIImageView {
4197
4211
  let requested = rawFit as? String ?? "fill"
4198
4212
  fit = ["fill", "contain", "cover", "none", "scale-down"].contains(requested) ? requested : "fill"
4199
4213
  position = Self.objectPosition(rawPosition)
4200
- if fittedImage == nil {
4201
- let child = UIImageView()
4202
- child.isUserInteractionEnabled = false
4203
- child.isAccessibilityElement = false
4204
- child.contentMode = .scaleToFill
4205
- insertSubview(child, at: 0)
4206
- fittedImage = child
4207
- }
4208
4214
  } else { fit = nil }
4209
- fittedImage?.isHidden = fit == nil
4210
- super.image = fit == nil ? storedImage : nil
4211
- fittedImage?.image = storedImage
4215
+ fittedImage.contentMode = fit == nil ? contentMode : .scaleToFill
4212
4216
  invalidateIntrinsicContentSize()
4213
4217
  setNeedsLayout()
4214
4218
  }
4215
4219
 
4220
+ override var contentMode: UIView.ContentMode {
4221
+ didSet {
4222
+ fittedImage.contentMode = fit == nil ? contentMode : .scaleToFill
4223
+ setNeedsLayout()
4224
+ }
4225
+ }
4226
+
4227
+ override func sizeThatFits(_ size: CGSize) -> CGSize {
4228
+ fit == nil ? fittedImage.sizeThatFits(size) : intrinsicContentSize
4229
+ }
4230
+
4231
+ override func tintColorDidChange() {
4232
+ super.tintColorDidChange()
4233
+ fittedImage.tintColor = tintColor
4234
+ }
4235
+
4216
4236
  var usesObjectGeometry: Bool { fit != nil }
4217
4237
  var objectCovers: Bool { fit == "cover" }
4218
4238
 
@@ -4234,7 +4254,8 @@ class CraftNativePositionedImageView: UIImageView {
4234
4254
 
4235
4255
  override func layoutSubviews() {
4236
4256
  super.layoutSubviews()
4237
- guard let fit, let child = fittedImage else { return }
4257
+ let child = fittedImage
4258
+ guard let fit else { child.frame = bounds; return }
4238
4259
  let source = storedImage?.craftIntrinsicSize ?? .zero
4239
4260
  guard source.width > 0, source.height > 0 else { child.frame = .zero; return }
4240
4261
  let contain = min(bounds.width / source.width, bounds.height / source.height)
@@ -9203,7 +9224,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9203
9224
  case "Icon":
9204
9225
  configureIcon(result as! UIImageView, props: props, style: style)
9205
9226
  case "Image":
9206
- let image = result as! UIImageView
9227
+ let image = result as! CraftNativePositionedImageView
9207
9228
  let imageID = ObjectIdentifier(image)
9208
9229
  if let tint = color(style["tintColor"]) {
9209
9230
  tintedImages.insert(imageID)
@@ -9215,7 +9236,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9215
9236
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
9216
9237
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
9217
9238
  image.contentMode = imageContentMode(requestedResizeMode)
9218
- (image as? CraftNativePositionedImageView)?.configureObjectGeometry(props: props, style: style)
9239
+ image.configureObjectGeometry(props: props, style: style)
9219
9240
  applyImageClipping(image, contentMode: image.contentMode, style: style)
9220
9241
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
9221
9242
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
@@ -10347,7 +10368,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10347
10368
  view.clipsToBounds = overflow == "hidden"
10348
10369
  || (view is UIScrollView && overflow != "visible")
10349
10370
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
10350
- || (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
10371
+ || ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
10351
10372
  // Per-corner rounding clips its content, as its mask did.
10352
10373
  || hasCornerRadii
10353
10374
  view.layer.masksToBounds = view.clipsToBounds
@@ -10659,7 +10680,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10659
10680
  /// The pixels a picture is drawn at, when its style fixes its size (a
10660
10681
  /// 40-point avatar, a card's photo): nil otherwise, for a picture whose
10661
10682
  /// own size lays it out, which must keep its full size.
10662
- static func displayPixels(of view: UIImageView) -> (size: CGSize, scale: CGFloat)? {
10683
+ static func displayPixels(of view: UIView) -> (size: CGSize, scale: CGFloat)? {
10663
10684
  let style = view.craftLayoutStyle
10664
10685
  let points: CGSize
10665
10686
  switch (style.width, style.height, style.aspectRatio) {
@@ -10742,16 +10763,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10742
10763
  return candidate
10743
10764
  }
10744
10765
 
10745
- private func applyImageClipping(_ view: UIImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
10766
+ private func applyImageClipping(_ view: CraftNativePositionedImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
10746
10767
  let authored = (style["overflow"] as? String) == "hidden"
10747
10768
  || (view.craftRequestedCornerRadius ?? 0) > 0
10748
10769
  || view.craftRequestedCornerRadii != nil
10749
- let clips = authored || contentMode == .scaleAspectFill || (view as? CraftNativePositionedImageView)?.usesObjectGeometry == true
10770
+ let clips = authored || contentMode == .scaleAspectFill || view.usesObjectGeometry
10750
10771
  view.clipsToBounds = clips
10751
10772
  view.layer.masksToBounds = clips
10752
10773
  }
10753
10774
 
10754
- private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
10775
+ private func updateImage(_ view: CraftNativePositionedImageView, source: Any?, defaultSource: Any? = nil) {
10755
10776
  let sourceObject = source as? [String: Any]
10756
10777
  let uri = (source as? String) ?? sourceObject?["uri"] as? String
10757
10778
  let headers = (sourceObject?["headers"] as? [String: Any])?.reduce(into: [String: String]()) { result, entry in
@@ -10793,7 +10814,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10793
10814
  if let url = imageURL(for: uri) {
10794
10815
  // Decoded at the size it is drawn, when the style fixes that size.
10795
10816
  let pixels = Self.displayPixels(of: view)
10796
- let fill = (view as? CraftNativePositionedImageView)?.objectCovers == true || view.contentMode == .scaleAspectFill
10817
+ let fill = view.objectCovers || view.contentMode == .scaleAspectFill
10797
10818
  // media.keep owns private/offline copies keyed by the authored URL.
10798
10819
  // Read before the decoded/network cache, without retaining private
10799
10820
  // pixels there after the app clears its media on sign-out.
@@ -10884,7 +10905,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10884
10905
  return !uri.contains(":") ? UIImage(named: uri) : nil
10885
10906
  }
10886
10907
 
10887
- private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
10908
+ private func imageFailure(_ view: CraftNativePositionedImageView, uri: String?, message: String) {
10888
10909
  imageErrors[ObjectIdentifier(view)] = message
10889
10910
  view.image = nil
10890
10911
  imageDidChange(view)
@@ -10895,19 +10916,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10895
10916
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
10896
10917
  }
10897
10918
 
10898
- private func imageDidChange(_ view: UIImageView) {
10919
+ private func imageDidChange(_ view: CraftNativePositionedImageView) {
10899
10920
  view.invalidateIntrinsicContentSize()
10900
10921
  CraftNativeFlexLayout.invalidateAncestors(of: view)
10901
10922
  }
10902
10923
 
10903
- private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
10924
+ private func imageForDisplay(_ image: UIImage, view: CraftNativePositionedImageView) -> UIImage {
10904
10925
  let displayed = image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
10905
10926
  displayed.craftIntrinsicSize = image.craftIntrinsicSize
10906
10927
  return displayed
10907
10928
  }
10908
10929
 
10909
10930
  private func emitImageEvent(
10910
- _ view: UIImageView,
10931
+ _ view: CraftNativePositionedImageView,
10911
10932
  handler: String?,
10912
10933
  nativeEvent: [String: Any] = [:]
10913
10934
  ) {
@@ -10961,7 +10982,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10961
10982
  switch role ?? type.lowercased() {
10962
10983
  case "tablist": traits.insert(.tabBar)
10963
10984
  case "button", "checkbox": traits.insert(.button)
10964
- case "image": traits.insert(.image)
10985
+ case "image", "imagebackground": traits.insert(.image)
10965
10986
  case "header": traits.insert(.header)
10966
10987
  case "link": traits.insert(.link)
10967
10988
  case "search": traits.insert(.searchField)
@@ -11023,11 +11044,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11023
11044
 
11024
11045
  @objc private func buttonPressed(_ sender: UIButton) {
11025
11046
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
11026
- if let target = linkTargets[ObjectIdentifier(sender)] {
11027
- hybridEvents?.nativeScreen(self, open: target, props: [:])
11047
+ let id = ObjectIdentifier(sender)
11048
+ // An authored handler owns navigation; the asynchronous bridge cannot
11049
+ // wait for preventDefault before opening the link.
11050
+ guard let handler = handlers[id] else {
11051
+ if let target = linkTargets[id] { hybridEvents?.nativeScreen(self, open: target, props: [:]) }
11028
11052
  return
11029
11053
  }
11030
- guard let handler = handlers[ObjectIdentifier(sender)] else { return }
11031
11054
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
11032
11055
  restoreFocus(focused)
11033
11056
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self, weak focused] in
@@ -11191,7 +11214,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11191
11214
 
11192
11215
  @objc private func solidSwitchChanged(_ sender: CraftNativeSolidSwitch) {
11193
11216
  guard sender.isEnabled, let handler = handlers[ObjectIdentifier(sender)] else { return }
11194
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
11217
+ sendAfterThumb(handler: handler, value: sender.isOn)
11195
11218
  }
11196
11219
 
11197
11220
  @objc private func switchChanged(_ sender: UISwitch) {
@@ -11202,7 +11225,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11202
11225
  self.switchUserValues.removeValue(forKey: id)
11203
11226
  }
11204
11227
  guard let handler = handlers[id] else { return }
11205
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
11228
+ sendAfterThumb(handler: handler, value: sender.isOn)
11229
+ }
11230
+
11231
+ /// A switch's value, told to the script after UIKit has been left to draw
11232
+ /// the thumb.
11233
+ ///
11234
+ /// On the default main-thread script a handler runs inline, inside the
11235
+ /// control's own action, so the touch's CATransaction stays open until the
11236
+ /// script has finished its work and whatever render it causes. UIKit has
11237
+ /// already set the new value by then, so the thumb does not travel: the
11238
+ /// switch sits still for as long as the script takes and then appears in
11239
+ /// its new state. Yielding the runloop turn first lets that transaction
11240
+ /// commit, the animation is handed to the render server, and the script's
11241
+ /// work no longer holds the thumb. A background script never blocks the
11242
+ /// main thread, so it is told at once.
11243
+ private func sendAfterThumb(handler: String, value: Bool) {
11244
+ let payload: [String: Any] = ["handlerName": handler, "nativeEvent": ["value": value]]
11245
+ guard !CraftNativeScriptThread.isBackground else {
11246
+ send(type: "EVENT", payload: payload)
11247
+ return
11248
+ }
11249
+ DispatchQueue.main.async { [weak self] in self?.send(type: "EVENT", payload: payload) }
11206
11250
  }
11207
11251
 
11208
11252
  @objc private func sliderChanged(_ sender: UISlider) {
@@ -11385,11 +11429,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11385
11429
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
11386
11430
  guard let view = sender.view else { return }
11387
11431
  let id = ObjectIdentifier(view)
11388
- if let target = linkTargets[id] {
11389
- hybridEvents?.nativeScreen(self, open: target, props: [:])
11432
+ guard let handler = handlers[id] else {
11433
+ if let target = linkTargets[id] { hybridEvents?.nativeScreen(self, open: target, props: [:]) }
11390
11434
  return
11391
11435
  }
11392
- guard let handler = handlers[id] else { return }
11393
11436
  if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
11394
11437
  let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
11395
11438
  UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in
@@ -69,6 +69,7 @@ final class CraftVoiceRecording {
69
69
  var recorder: CraftVoiceRecorder? // audio queue only
70
70
  var completion: ((Result<Any, CraftNativeActionError>) -> Void)? // main only
71
71
  var deadline: DispatchWorkItem?
72
+ var stopping = false // main only
72
73
  private let lock = NSLock()
73
74
  private var cancelled = false
74
75
  init(key: String, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
@@ -86,15 +87,29 @@ final class CraftVoiceRecording {
86
87
  }
87
88
  }
88
89
  private var current: Session?
90
+ /// Each microphone step in the unified log (`subsystem == "craft.native"`,
91
+ /// category `voice-recording`), so a recording that never finished says
92
+ /// whether its stop arrived, and where AVFoundation held it.
93
+ private static func trace(_ message: String) {
94
+ CraftNativeConsole.write("info", category: "voice-recording", message: message)
95
+ }
89
96
  private let audioQueue: DispatchQueue
90
97
  private let permission: (@escaping (Bool) -> Void) -> Void
91
98
  private let makeRecorder: (URL) throws -> CraftVoiceRecorder
92
99
  private let deactivate: () -> Void
100
+ /// Releasing the audio session runs here, never on `audioQueue`: a
101
+ /// deactivation that stalls (seen on simulators with a virtual input)
102
+ /// must not hold the next recording's start or stop behind it.
103
+ private let releaseQueue = DispatchQueue(label: "craft.voice-recording-release", qos: .utility)
104
+ private let generationLock = NSLock()
105
+ private var generation = 0
93
106
  private let startupTimeout: TimeInterval
107
+ private let stopTimeout: TimeInterval
94
108
  private var backgroundObserver: NSObjectProtocol?
95
109
 
96
110
  init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
97
111
  startupTimeout: TimeInterval = 15,
112
+ stopTimeout: TimeInterval = 10,
98
113
  permission: @escaping (@escaping (Bool) -> Void) -> Void = { answer in
99
114
  #if CRAFT_PERMISSION_MICROPHONE
100
115
  AVAudioSession.sharedInstance().requestRecordPermission(answer)
@@ -116,6 +131,7 @@ final class CraftVoiceRecording {
116
131
  Self.prepareForLaunch()
117
132
  self.audioQueue = audioQueue
118
133
  self.startupTimeout = startupTimeout
134
+ self.stopTimeout = stopTimeout
119
135
  self.permission = permission
120
136
  self.makeRecorder = makeRecorder
121
137
  self.deactivate = deactivate
@@ -128,7 +144,17 @@ final class CraftVoiceRecording {
128
144
  session.recorder?.stop()
129
145
  session.recorder = nil
130
146
  if let file = session.file { CraftNativeFiles.remove(file.path) }
131
- deactivate()
147
+ releaseAudioSession()
148
+ }
149
+
150
+ /// Deactivate off the recording queue, and not at all once a newer
151
+ /// recording has started: it would only take the microphone from it.
152
+ private func releaseAudioSession() {
153
+ let mine = generationLock.withLock { generation }
154
+ releaseQueue.async { [weak self] in
155
+ guard let self, self.generationLock.withLock({ self.generation }) == mine else { return }
156
+ self.deactivate()
157
+ }
132
158
  }
133
159
 
134
160
  private func clear(_ error: CraftNativeActionError = CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")) {
@@ -150,6 +176,7 @@ final class CraftVoiceRecording {
150
176
  private func performOnMain(method: String, args: [Any], isActive: @escaping () -> Bool, completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
151
177
  func fail(_ code: String, _ message: String) { completion(.failure(CraftNativeActionError(code: code, message: message))) }
152
178
  guard let key = args.first as? String, UUID(uuidString: key) != nil else { fail("INVALID_ARGUMENT", "A recording owner UUID is required"); return }
179
+ Self.trace("\(method) \(key.prefix(8)) (current \(current.map { String($0.key.prefix(8)) } ?? "none"))")
153
180
  if method == "cancel" {
154
181
  if current?.key == key { clear() }
155
182
  completion(.success(true))
@@ -157,11 +184,36 @@ final class CraftVoiceRecording {
157
184
  }
158
185
  if method == "stop" {
159
186
  guard let session = current, session.key == key, let file = session.file else { fail("RECORDING_NOT_FOUND", "This recording is no longer available"); return }
187
+ guard !session.stopping else { fail("RECORDING_BUSY", "This recording is already finishing"); return }
188
+ session.stopping = true
189
+ // Answered exactly once: by the encoder's result, or by the
190
+ // deadline when AVFoundation never returns (a stalled encoder or
191
+ // audio route), which then erases the recording like a cancel.
192
+ var settled = false
193
+ var timeout: DispatchWorkItem?
194
+ func settle(_ result: Result<Any, CraftNativeActionError>) {
195
+ timeout?.cancel()
196
+ guard !settled else { return }
197
+ settled = true
198
+ completion(result)
199
+ }
200
+ let late = CraftNativeActionError(code: "RECORDING_TIMEOUT", message: "The recording took too long to finish. Please try again.")
201
+ timeout = DispatchWorkItem { [weak self] in
202
+ guard !settled else { return }
203
+ Self.trace("stop \(key.prefix(8)) timed out waiting for the encoder")
204
+ if let self, self.current === session { self.clear(late) }
205
+ settle(.failure(late))
206
+ }
207
+ DispatchQueue.main.asyncAfter(deadline: .now() + stopTimeout, execute: timeout!)
160
208
  audioQueue.async {
161
- guard !session.isCancelled else { return DispatchQueue.main.async { fail("RECORDING_CANCELLED", "Recording was cancelled") } }
209
+ guard !session.isCancelled else {
210
+ return DispatchQueue.main.async { settle(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled"))) }
211
+ }
162
212
  session.recorder?.stop()
163
213
  session.recorder = nil
164
- self.deactivate()
214
+ Self.trace("stop \(key.prefix(8)): encoder stopped")
215
+ // Read the finished file before releasing the audio session:
216
+ // the answer must not wait on a deactivation that can stall.
165
217
  let result: Result<Any, CraftNativeActionError>
166
218
  if let audio = try? AVAudioFile(forReading: file), audio.length > 0 {
167
219
  let duration = Double(audio.length) / audio.processingFormat.sampleRate
@@ -170,14 +222,17 @@ final class CraftVoiceRecording {
170
222
  result = .failure(CraftNativeActionError(code: "EMPTY_RECORDING", message: "The recording was empty"))
171
223
  }
172
224
  DispatchQueue.main.async {
225
+ guard !settled else { return }
173
226
  guard self.current === session, isActive(), !session.isCancelled else {
174
227
  if self.current === session { self.clear() }
175
- fail("RECORDING_CANCELLED", "Recording was cancelled")
228
+ settle(.failure(CraftNativeActionError(code: "RECORDING_CANCELLED", message: "Recording was cancelled")))
176
229
  return
177
230
  }
178
231
  if case .failure = result { self.clear() }
179
- completion(result)
232
+ Self.trace("stop \(key.prefix(8)) answered")
233
+ settle(result)
180
234
  }
235
+ self.releaseAudioSession()
181
236
  }
182
237
  return
183
238
  }
@@ -209,6 +264,7 @@ final class CraftVoiceRecording {
209
264
  self.audioQueue.async {
210
265
  do {
211
266
  guard !session.isCancelled else { return }
267
+ self.generationLock.withLock { self.generation += 1 }
212
268
  session.recorder = try self.makeRecorder(file)
213
269
  guard !session.isCancelled else { self.erase(session); return }
214
270
  guard session.recorder?.record(forDuration: max(0.5, seconds - 0.25)) == true else {
@@ -220,6 +276,7 @@ final class CraftVoiceRecording {
220
276
  if self.current === session { self.clear() }
221
277
  return
222
278
  }
279
+ Self.trace("start \(key.prefix(8)): recording")
223
280
  session.finish(.success(true))
224
281
  }
225
282
  }
@@ -1,6 +1,8 @@
1
1
  #import "CraftTimedInput.h"
2
2
  #import <UIKit/UIKit.h>
3
3
  #import <math.h>
4
+ #import <dlfcn.h>
5
+ #import <XCTest/XCTest.h>
4
6
 
5
7
  // XCTest's own touch-record API is private. Look up classes and check every
6
8
  // selector at runtime; unsupported Xcode versions fail rather than skipping or
@@ -12,6 +14,16 @@
12
14
  - (id)initWithName:(NSString *)name interfaceOrientation:(NSInteger)orientation;
13
15
  - (void)addPointerEventPath:(id)path;
14
16
  - (BOOL)synthesizeWithError:(NSError **)error;
17
+ + (id)eventStreamWithEventActions:(void (^)(id))actions;
18
+ - (void)advanceTime:(double)seconds;
19
+ - (void)touchDown:(CGPoint)point;
20
+ - (void)liftUp:(CGPoint)point;
21
+ - (id)underlyingPlayer;
22
+ - (BOOL)prewarmForEventStream:(id)stream withError:(NSError **)error;
23
+ - (void)setNeedsInitialDelay:(BOOL)value;
24
+ - (void)setIgnoresCompletionDelay:(BOOL)value;
25
+ - (void)playEventStream:(id)stream options:(id)options completion:(void (^)(void))completion;
26
+ - (void)tearDown;
15
27
  @end
16
28
 
17
29
  @implementation CraftTimedInput
@@ -21,6 +33,68 @@
21
33
  return NO;
22
34
  }
23
35
 
36
+ // The simulator's XCTest touch-path translator omits idle intervals between
37
+ // fingers and rounds stationary moves to its sample interval. Submit stationary,
38
+ // non-overlapping taps as one native HID stream with explicit clock advances.
39
+ // Moving/overlapping paths retain the existing XCTest gesture implementation.
40
+ + (BOOL)synthesizeStationaryPaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
41
+ static dispatch_once_t once;
42
+ static void *recap;
43
+ dispatch_once(&once, ^{
44
+ recap = dlopen("/System/Library/PrivateFrameworks/Recap.framework/Recap", RTLD_NOW | RTLD_LOCAL);
45
+ });
46
+ Class streamClass = NSClassFromString(@"RCPSyntheticEventStream");
47
+ Class playerClass = NSClassFromString(@"RCPInlinePlayer");
48
+ if (!recap || ![streamClass respondsToSelector:@selector(eventStreamWithEventActions:)])
49
+ return [self fail:@"Native timed tap streams are unavailable on this runtime" error:error];
50
+ for (NSString *name in @[@"advanceTime:", @"touchDown:", @"liftUp:"])
51
+ if (![streamClass instancesRespondToSelector:NSSelectorFromString(name)])
52
+ return [self fail:@"Native timed tap stream cannot preserve the schedule" error:error];
53
+ for (NSString *name in @[@"underlyingPlayer", @"setNeedsInitialDelay:", @"setIgnoresCompletionDelay:",
54
+ @"playEventStream:options:completion:", @"tearDown"])
55
+ if (![playerClass instancesRespondToSelector:NSSelectorFromString(name)])
56
+ return [self fail:@"Native timed tap playback is unavailable" error:error];
57
+
58
+ id stream = [streamClass eventStreamWithEventActions:^(id builder) {
59
+ double previous = 0;
60
+ for (NSArray *points in paths) {
61
+ NSDictionary *first = points.firstObject, *last = points.lastObject;
62
+ double start = [first[@"at"] doubleValue], end = [last[@"at"] doubleValue];
63
+ CGPoint point = CGPointMake(frame.origin.x + [first[@"x"] doubleValue] * frame.size.width,
64
+ frame.origin.y + [first[@"y"] doubleValue] * frame.size.height);
65
+ [builder advanceTime:start - previous];
66
+ [builder touchDown:point];
67
+ [builder advanceTime:end - start];
68
+ [builder liftUp:point];
69
+ previous = end;
70
+ }
71
+ }];
72
+ if (!stream) return [self fail:@"Native timed tap stream could not be constructed" error:error];
73
+ id player = [playerClass new];
74
+ id underlying = [player underlyingPlayer];
75
+ if (![underlying respondsToSelector:@selector(prewarmForEventStream:withError:)]) {
76
+ [player tearDown];
77
+ return [self fail:@"Native timed tap delivery cannot be prepared" error:error];
78
+ }
79
+ // Preparing the HID delivery service emits no touches. Do not add a warm-up
80
+ // touch or an initial/completion sleep to make the receiver appear reliable.
81
+ if (![underlying prewarmForEventStream:stream withError:error]) {
82
+ [player tearDown];
83
+ return NO;
84
+ }
85
+ [player setNeedsInitialDelay:NO];
86
+ [player setIgnoresCompletionDelay:YES];
87
+ XCTestExpectation *done = [[XCTestExpectation alloc] initWithDescription:@"Native timed tap completion"];
88
+ [player playEventStream:stream options:nil completion:^{
89
+ [player tearDown];
90
+ [done fulfill];
91
+ }];
92
+ // Validated streams end within 12s. A timeout is a failure, never a retry.
93
+ if ([XCTWaiter waitForExpectations:@[done] timeout:20] != XCTWaiterResultCompleted)
94
+ return [self fail:@"Native timed tap playback did not complete" error:error];
95
+ return YES;
96
+ }
97
+
24
98
  + (BOOL)synthesizePaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
25
99
  if (![paths isKindOfClass:NSArray.class] || paths.count == 0 || paths.count > 64
26
100
  || CGRectIsEmpty(frame) || !isfinite(frame.size.width) || !isfinite(frame.size.height))
@@ -43,6 +117,21 @@
43
117
  previous = at;
44
118
  }
45
119
  }
120
+ NSArray *ordered = [paths sortedArrayUsingComparator:^NSComparisonResult(NSArray *left, NSArray *right) {
121
+ return [left.firstObject[@"at"] compare:right.firstObject[@"at"]];
122
+ }];
123
+ BOOL stationary = [ordered.firstObject[0][@"at"] doubleValue] == 0;
124
+ double previousEnd = 0;
125
+ for (NSArray *points in ordered) {
126
+ NSDictionary *first = points.firstObject;
127
+ if ([first[@"at"] doubleValue] < previousEnd) stationary = NO;
128
+ for (NSDictionary *point in points)
129
+ if ([point[@"x"] doubleValue] != [first[@"x"] doubleValue]
130
+ || [point[@"y"] doubleValue] != [first[@"y"] doubleValue]) stationary = NO;
131
+ previousEnd = [points.lastObject[@"at"] doubleValue];
132
+ }
133
+ if (stationary) return [self synthesizeStationaryPaths:ordered frame:frame error:error];
134
+
46
135
  Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
47
136
  Class pathClass = NSClassFromString(@"XCPointerEventPath");
48
137
  for (NSString *name in @[@"initWithName:interfaceOrientation:", @"addPointerEventPath:", @"synthesizeWithError:"])
@@ -68,7 +157,7 @@
68
157
  [path liftUpAtOffset:[points.lastObject[@"at"] doubleValue]];
69
158
  [record addPointerEventPath:path];
70
159
  }
71
- // One dispatch retains the native offsets between different fingers/taps.
160
+ // One dispatch preserves the existing moving/overlapping gesture path.
72
161
  // Individual XCUICoordinate.tap() calls would wait for app quiescence.
73
162
  return [record synthesizeWithError:error];
74
163
  }
@@ -42,7 +42,7 @@ import Darwin
42
42
  /// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
43
43
  /// selected <tab> the tab bar's selected button is this one
44
44
  /// tab <name> tap a tab bar button
45
- /// scrollto <text> swipes up (at most 8 times) until text is hittable
45
+ /// scrollto <text> at most 12 drags until the target is hittable and visible
46
46
  /// swipe up|down|left|right
47
47
  /// drag x1 y1 x2 y2 slow app-relative drag, held 0.3s before release
48
48
  /// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
@@ -143,13 +143,29 @@ final class DriverTests: XCTestCase {
143
143
  return any.exists ? any : nil
144
144
  }
145
145
 
146
+ /// Where a label is looked for, in order: the topmost presented alert,
147
+ /// then the topmost sheet, then the whole app. A sheet's "Sign out" and
148
+ /// the row that opened it share a label, and the row behind the sheet
149
+ /// is still reported hittable, so the presented one has to win.
150
+ func searchScopes<Scope>(alerts: [Scope], sheets: [Scope], app: Scope) -> [Scope] {
151
+ [alerts.last, sheets.last].compactMap { $0 } + [app]
152
+ }
153
+
154
+ func presentedScopes() -> [XCUIElement] {
155
+ let alerts = app.alerts.allElementsBoundByIndex.filter { $0.exists }
156
+ let sheets = app.sheets.allElementsBoundByIndex.filter { $0.exists }
157
+ return searchScopes(alerts: alerts, sheets: sheets, app: app as XCUIElement)
158
+ }
159
+
146
160
  func exact(_ label: String) -> XCUIElement? {
147
161
  let predicate = NSPredicate(format: "label == %@", label)
148
- for query in [app.buttons, app.links, app.staticTexts] {
149
- let matches = query.matching(predicate)
150
- for index in 0..<min(matches.count, 25) {
151
- let candidate = matches.element(boundBy: index)
152
- if candidate.exists && candidate.isHittable { return candidate }
162
+ for scope in presentedScopes() {
163
+ for query in [scope.buttons, scope.links, scope.staticTexts] {
164
+ let matches = query.matching(predicate)
165
+ for index in 0..<min(matches.count, 25) {
166
+ let candidate = matches.element(boundBy: index)
167
+ if candidate.exists && candidate.isHittable { return candidate }
168
+ }
153
169
  }
154
170
  }
155
171
  return nil
@@ -164,6 +180,27 @@ final class DriverTests: XCTestCase {
164
180
  return nil
165
181
  }
166
182
 
183
+ /// A short target fits entirely; an oversized target fills the viewport.
184
+ func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
185
+ let visible = target.intersection(viewport)
186
+ return !target.isEmpty && !viewport.isEmpty && !visible.isNull
187
+ && visible.width >= min(target.width, viewport.width)
188
+ && visible.height >= min(target.height, viewport.height)
189
+ }
190
+
191
+ func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
192
+ guard let target, target.exists, target.isHittable,
193
+ visibleScrollFrame(target.frame, in: app.frame) else { return false }
194
+ // Hittable alone accepts controls clipped by the scroll view's edge.
195
+ // Only an ancestor scroll view clips this target, not its siblings.
196
+ let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
197
+ for scroll in app.scrollViews.allElementsBoundByIndex {
198
+ if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
199
+ !visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
200
+ }
201
+ return true
202
+ }
203
+
167
204
  /// About a third of a screen of scrolling, as a held drag.
168
205
  ///
169
206
  /// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
@@ -590,13 +627,13 @@ final class DriverTests: XCTestCase {
590
627
  case "scrollto":
591
628
  var found = element(containing: arg)
592
629
  var drags = 0
593
- while (found == nil || !(found!.isHittable)) && drags < 12 {
630
+ while !scrollTargetVisible(found) && drags < 12 {
594
631
  let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
595
632
  scrollPage(towardTop: towardTop)
596
633
  drags += 1
597
634
  found = element(containing: arg)
598
635
  }
599
- check(found != nil && found!.isHittable, "scrollto \(arg)")
636
+ check(scrollTargetVisible(found), "scrollto \(arg)")
600
637
  case "swipe":
601
638
  switch arg {
602
639
  case "up": app.swipeUp()
@@ -644,7 +681,9 @@ final class DriverTests: XCTestCase {
644
681
  // A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
645
682
  let label = arg.isEmpty ? "Not Now" : arg
646
683
  let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
647
- let candidates = [app.buttons[label], springboard.buttons[label]]
684
+ // The app's own alert or sheet first (its button can share a
685
+ // label with the control behind it), then the app, then iOS.
686
+ let candidates = { self.presentedScopes().map { $0.buttons[label] } + [springboard.buttons[label]] }
648
687
  let end = Date().addingTimeInterval(6)
649
688
  // A tap is not a dismissal. iOS 26 draws "Save Password?" as a
650
689
  // sheet that is in the tree, and hittable, while it still slides
@@ -655,7 +694,7 @@ final class DriverTests: XCTestCase {
655
694
  var tapped = false
656
695
  var gone = false
657
696
  while !gone && Date() < end {
658
- guard let button = candidates.first(where: { $0.exists && $0.isHittable }) else {
697
+ guard let button = candidates().first(where: { $0.exists && $0.isHittable }) else {
659
698
  if tapped { gone = true } else { Thread.sleep(forTimeInterval: 0.5) }
660
699
  continue
661
700
  }
@@ -43,9 +43,9 @@ import process from "process";
43
43
  var macosToolchain = {
44
44
  zig: "0.18.0-dev.252+86dc5ce25",
45
45
  sources: {
46
- "zig-js": { repository: "zig-utils/zig-js", revision: "a6739721273ae0f13d36fb3422052a588c5079a8", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "59497a090677aeb317e52ac55329e6a680457727", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
47
47
  "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
48
- "zig-gc": { repository: "zig-utils/zig-gc", revision: "0285ef801dd221a4ae8e88b31a09ae31627657a3", paths: ["src"] }
48
+ "zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
49
49
  }
50
50
  };
51
51
  var defaultRun = async (args, cwd) => {
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
9
9
  readonly sources: {
10
10
  readonly 'zig-js': {
11
11
  readonly repository: 'zig-utils/zig-js';
12
- readonly revision: 'a6739721273ae0f13d36fb3422052a588c5079a8';
12
+ readonly revision: '59497a090677aeb317e52ac55329e6a680457727';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -19,7 +19,7 @@ export declare const macosToolchain: {
19
19
  };
20
20
  readonly 'zig-gc': {
21
21
  readonly repository: 'zig-utils/zig-gc';
22
- readonly revision: '0285ef801dd221a4ae8e88b31a09ae31627657a3';
22
+ readonly revision: '73c802e05768a729614e9c4296abb118aa06faf9';
23
23
  readonly paths: readonly ['src'];
24
24
  };
25
25
  };
@@ -21,6 +21,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
21
21
  let trafficLights: CGPoint?
22
22
  /// The screen has work to finish before the app quits (`craft.desktop.onQuit`).
23
23
  var holdsQuit = false
24
+ /// The content width the screen last heard, so a resize that changes nothing sends nothing.
25
+ var reportedViewportWidth: CGFloat = -1
24
26
  init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
25
27
  self.name = name
26
28
  runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
@@ -49,9 +51,22 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
49
51
  if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
50
52
  window.makeKeyAndOrderFront(nil)
51
53
  placeTrafficLights()
54
+ // The screen starts knowing its width, so `md:`/`lg:` classes and `matchMedia` are right on the first frame.
55
+ let size = window.contentLayoutRect.size
56
+ reportedViewportWidth = size.width
57
+ runtime.context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(size.width)); __stxNativeBridge.viewportHeight = \(Double(size.height));")
52
58
  runtime.load(application.bundle)
53
59
  }
54
60
 
61
+ /// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
62
+ /// since a live resize lays out every frame.
63
+ func reportViewport() {
64
+ let size = window.contentLayoutRect.size
65
+ guard size.width > 0, abs(size.width - reportedViewportWidth) >= 0.5 else { return }
66
+ reportedViewportWidth = size.width
67
+ runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
68
+ }
69
+
55
70
  /// AppKit lays the title bar out again on resize, full screen and appearance changes, so this runs after each.
56
71
  func placeTrafficLights() {
57
72
  guard let inset = trafficLights, let close = window.standardWindowButton(.closeButton), let titlebar = close.superview?.superview else { return }
@@ -64,8 +79,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
64
79
  window.standardWindowButton(type)?.setFrameOrigin(CGPoint(x: inset.x + CGFloat(index) * (size.width + 6), y: (height - size.height) / 2))
65
80
  }
66
81
  }
67
- func windowDidResize(_ notification: Notification) { placeTrafficLights() }
68
- func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights() }
82
+ func windowDidResize(_ notification: Notification) { placeTrafficLights(); reportViewport() }
83
+ func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights(); reportViewport() }
69
84
  func windowShouldClose(_ sender: NSWindow) -> Bool {
70
85
  if name == "main" { sender.orderOut(nil); return false }
71
86
  return true
@@ -213,6 +228,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
213
228
  defer { CATransaction.commit() }
214
229
  let scheme = CraftMacColor.isDark ? "dark" : "light"
215
230
  for scene in scenes.values {
231
+ // AppKit lays the title bar out again for the new appearance, after this returns.
232
+ scene.placeTrafficLights()
233
+ DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
216
234
  scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
217
235
  // The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
218
236
  scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
@@ -455,7 +473,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
455
473
  let box = scene.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == target }) else { return ["ok": false, "error": "No control \(action["target"] ?? "")"] }
456
474
  let text = action["text"] as? String ?? ""
457
475
  switch kind {
458
- case "press": if let button = box.control as? NSButton { button.performClick(nil) } else { box.fire("onPress") }
476
+ case "press": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.fire("onPress") }
459
477
  case "type":
460
478
  if let field = box.control as? NSTextField { field.stringValue = text }
461
479
  else if let view = (box.control as? NSScrollView)?.documentView as? NSTextView { view.string = text }
@@ -500,7 +518,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
500
518
  let editor = (target as? NSTextField)?.currentEditor() as? NSTextView ?? target as? NSTextView
501
519
  guard let editor else { return ["ok": false, "error": "Not a text input"] }
502
520
  editor.doCommand(by: command)
503
- case "click": if let button = box.control as? NSButton { button.performClick(nil) } else { box.pressed() }
521
+ case "click": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.pressed() }
504
522
  case "fill":
505
523
  let value = action["value"] as? String ?? ""
506
524
  if let field = box.control as? NSTextField { field.stringValue = value; box.input(value) }
@@ -536,7 +554,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
536
554
  return node
537
555
  }
538
556
  func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
539
- return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "errors": scene.runtime.errors]
557
+ return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
540
558
  }
541
559
  let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
542
560
  return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
@@ -318,7 +318,10 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
318
318
  case "Picker":
319
319
  let picker = NSPopUpButton(frame: .zero, pullsDown: false)
320
320
  picker.target = self; picker.action = #selector(changed); control = picker
321
- case "Switch", "Checkbox":
321
+ case "Switch":
322
+ // A toggle, as System Settings draws one; a checkbox is the Checkbox primitive's.
323
+ let toggle = NSSwitch(); toggle.target = self; toggle.action = #selector(changed); control = toggle
324
+ case "Checkbox":
322
325
  let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(changed)); control = button
323
326
  case "Icon", "Image":
324
327
  let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; control = image
@@ -407,6 +410,11 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
407
410
  text.maximumNumberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue ?? 0
408
411
  text.lineBreakMode = text.maximumNumberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
409
412
  }
413
+ if let toggle = control as? NSSwitch {
414
+ toggle.state = (props["value"] as? Bool ?? props["checked"] as? Bool ?? false) ? .on : .off
415
+ toggle.isEnabled = props["disabled"] as? Bool != true
416
+ if let label { toggle.setAccessibilityLabel(label) }
417
+ }
410
418
  if let button = control as? NSButton {
411
419
  let title = props["title"] as? String ?? nativeText(node)
412
420
  button.title = props["iconOnly"] as? Bool == true ? "" : title
@@ -811,7 +819,8 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
811
819
  func fire(_ name: String, _ payload: [String: Any] = [:]) { renderer?.runtime.event(events[name], payload: payload) }
812
820
  @objc func pressed() { fire("onPress") }
813
821
  @objc func changed() {
814
- let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? ((control as? NSButton)?.state == .on)
822
+ let state: NSControl.StateValue? = (control as? NSSwitch)?.state ?? (control as? NSButton)?.state
823
+ let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? (state == .on)
815
824
  fire("onValueChange", ["value": value, "nativeEvent": ["value": value]])
816
825
  }
817
826
  func controlTextDidChange(_ notification: Notification) { if let field = notification.object as? NSTextField { input(field.stringValue) } }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.424",
3
+ "version": "0.0.435",
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>",