@craft-native/ios 0.0.369 → 0.0.375

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.
@@ -133,6 +133,7 @@ enum CraftNativeFiles {
133
133
  }
134
134
 
135
135
  if source == "camera" {
136
+ #if CRAFT_PERMISSION_CAMERA
136
137
  guard cameraEnabled else {
137
138
  done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera is disabled")))
138
139
  return
@@ -150,6 +151,9 @@ enum CraftNativeFiles {
150
151
  activePicker = nil
151
152
  done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
152
153
  }
154
+ #else
155
+ done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera was not enabled when this app was built")))
156
+ #endif
153
157
  return
154
158
  }
155
159
  guard source == "library" else {
@@ -247,6 +251,7 @@ enum CraftNativeFiles {
247
251
  }
248
252
  }
249
253
 
254
+ #if CRAFT_PERMISSION_CAMERA
250
255
  private final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
251
256
  let maxPixels: Int
252
257
  let quality: Double
@@ -287,6 +292,8 @@ enum CraftNativeFiles {
287
292
  }
288
293
  }
289
294
 
295
+ #endif
296
+
290
297
  // MARK: Picking a video
291
298
 
292
299
  /// System pickers grant access to just the selected video. Copy it while
@@ -2245,6 +2245,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
2245
2245
  }
2246
2246
 
2247
2247
  private static func locationPermissionGranted() -> Bool {
2248
+ #if CRAFT_PERMISSION_LOCATION
2248
2249
  let status: CLAuthorizationStatus
2249
2250
  if #available(iOS 14.0, *) {
2250
2251
  status = CLLocationManager().authorizationStatus
@@ -2252,6 +2253,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
2252
2253
  status = CLLocationManager.authorizationStatus()
2253
2254
  }
2254
2255
  return status == .authorizedAlways || status == .authorizedWhenInUse
2256
+ #else
2257
+ return false
2258
+ #endif
2255
2259
  }
2256
2260
  }
2257
2261
 
@@ -5316,6 +5320,115 @@ private final class CraftNativeButton: UIButton {
5316
5320
  }
5317
5321
  }
5318
5322
 
5323
+ /// Opt-in solid switch. Draws only public UIControl content; it neither walks
5324
+ /// UISwitch's private views nor changes UIKit animations elsewhere in the app.
5325
+ final class CraftNativeSolidSwitch: UIControl {
5326
+ private(set) var isOn = false
5327
+ var onColor: UIColor? { didSet { setNeedsDisplay() } }
5328
+ var offColor: UIColor? { didSet { setNeedsDisplay() } }
5329
+ var thumbColor: UIColor? { didSet { setNeedsDisplay() } }
5330
+ private let naturalSize = UISwitch().intrinsicContentSize
5331
+ private var touchOrigin: CGPoint?
5332
+ private var dragPosition: CGFloat?
5333
+ override var intrinsicContentSize: CGSize { naturalSize }
5334
+ override var isEnabled: Bool {
5335
+ didSet {
5336
+ if !isEnabled { cancelTracking(with: nil) }
5337
+ refreshAccessibility()
5338
+ setNeedsDisplay()
5339
+ }
5340
+ }
5341
+ override var canBecomeFirstResponder: Bool { isEnabled }
5342
+
5343
+ override init(frame: CGRect) {
5344
+ super.init(frame: frame)
5345
+ isOpaque = false
5346
+ contentMode = .redraw
5347
+ isAccessibilityElement = true
5348
+ refreshAccessibility()
5349
+ }
5350
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
5351
+
5352
+ func setOn(_ value: Bool) {
5353
+ isOn = value
5354
+ refreshAccessibility()
5355
+ setNeedsDisplay()
5356
+ }
5357
+
5358
+ func refreshAccessibility() {
5359
+ accessibilityTraits = .button
5360
+ if #available(iOS 17.0, *) { accessibilityTraits.insert(.toggleButton) }
5361
+ if isOn { accessibilityTraits.insert(.selected) }
5362
+ if !isEnabled { accessibilityTraits.insert(.notEnabled) }
5363
+ accessibilityValue = isOn ? "1" : "0"
5364
+ }
5365
+
5366
+ private var trackRect: CGRect {
5367
+ let size = CGSize(width: min(bounds.width, naturalSize.width), height: min(bounds.height, naturalSize.height))
5368
+ return CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height)
5369
+ }
5370
+ var thumbRect: CGRect {
5371
+ let track = trackRect.insetBy(dx: 2, dy: 2)
5372
+ let onRight = effectiveUserInterfaceLayoutDirection != .rightToLeft
5373
+ let position = dragPosition ?? ((isOn == onRight) ? 1 : 0)
5374
+ return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
5375
+ }
5376
+ override func draw(_ rect: CGRect) {
5377
+ let track = trackRect
5378
+ guard track.width > 4, track.height > 4 else { return }
5379
+ (isEnabled ? (isOn ? onColor ?? .systemGreen : offColor ?? .systemGray4) : UIColor.systemGray4).setFill()
5380
+ UIBezierPath(roundedRect: track, cornerRadius: track.height / 2).fill()
5381
+ // The solid variant deliberately ignores thumb alpha. No transient
5382
+ // glass, scaling, fade, implicit layer animation or delayed redraw.
5383
+ (thumbColor ?? .white).withAlphaComponent(1).setFill()
5384
+ UIBezierPath(ovalIn: thumbRect).fill()
5385
+ }
5386
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
5387
+ super.traitCollectionDidChange(previousTraitCollection)
5388
+ setNeedsDisplay()
5389
+ }
5390
+ override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
5391
+ guard isEnabled else { return false }
5392
+ touchOrigin = touch.location(in: self)
5393
+ dragPosition = nil
5394
+ return true
5395
+ }
5396
+ func updateDrag(to point: CGPoint) {
5397
+ let track = trackRect.insetBy(dx: 2, dy: 2)
5398
+ dragPosition = min(1, max(0, (point.x - track.minX - track.height / 2) / max(1, track.width - track.height)))
5399
+ setNeedsDisplay()
5400
+ }
5401
+ override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
5402
+ guard isEnabled, let origin = touchOrigin else { return false }
5403
+ let point = touch.location(in: self)
5404
+ if dragPosition != nil || abs(point.x - origin.x) > 6 { updateDrag(to: point) }
5405
+ return true
5406
+ }
5407
+ override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
5408
+ defer { touchOrigin = nil; dragPosition = nil; setNeedsDisplay() }
5409
+ guard isEnabled, let touch, touchOrigin != nil,
5410
+ bounds.insetBy(dx: -10, dy: -10).contains(touch.location(in: self)) else { return }
5411
+ let next = dragPosition.map { ($0 >= 0.5) == (effectiveUserInterfaceLayoutDirection != .rightToLeft) } ?? !isOn
5412
+ if next != isOn { setOn(next); sendActions(for: .valueChanged) }
5413
+ }
5414
+ override func cancelTracking(with event: UIEvent?) {
5415
+ touchOrigin = nil
5416
+ dragPosition = nil
5417
+ setNeedsDisplay()
5418
+ }
5419
+ override func accessibilityActivate() -> Bool {
5420
+ guard isEnabled else { return false }
5421
+ setOn(!isOn)
5422
+ sendActions(for: .valueChanged)
5423
+ return true
5424
+ }
5425
+ override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
5426
+ if isEnabled, presses.contains(where: { $0.key?.keyCode == .keyboardSpacebar || $0.key?.keyCode == .keyboardReturnOrEnter }) {
5427
+ _ = accessibilityActivate()
5428
+ } else { super.pressesEnded(presses, with: event) }
5429
+ }
5430
+ }
5431
+
5319
5432
  /// A native checkbox button. UIKit has no checkbox control, so use the system
5320
5433
  /// square/checkmark symbols while keeping the control's title and accessibility
5321
5434
  /// state native and keyboard/touch reachable.
@@ -6786,6 +6899,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6786
6899
  actionSheet: function(options) { return request('Dialog', 'actionSheet', [options || {}], true).then(function(id) { return typeof id === 'string' ? id : null; }); }
6787
6900
  };
6788
6901
  }
6902
+ if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('health') >= 0) {
6903
+ var health = craft.health || {};
6904
+ var discardWorkout = function() { return request('Health', 'discardWorkout', []); };
6905
+ health.discardWorkout = discardWorkout;
6906
+ Object.defineProperty(craft, 'health', {
6907
+ configurable: true, enumerable: true,
6908
+ get: function() { return health; },
6909
+ set: function(value) {
6910
+ if (!value || typeof value !== 'object') return;
6911
+ value.discardWorkout = discardWorkout;
6912
+ health = value;
6913
+ }
6914
+ });
6915
+ }
6789
6916
  if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('shortcuts') >= 0) {
6790
6917
  var shortcuts = {
6791
6918
  set: function(items) { return request('Shortcuts', 'set', [items]); },
@@ -7017,6 +7144,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7017
7144
  keep('navigation', {
7018
7145
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
7019
7146
  });
7147
+ // shortcuts already has a capability-owned accessor above.
7148
+ // Do not wrap it in keep(): its adopt setter would let a
7149
+ // bundle replace native set/clear during bootstrap.
7020
7150
  })();
7021
7151
  """)
7022
7152
  }
@@ -8263,6 +8393,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8263
8393
  private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
8264
8394
  let type = canonicalType(node["type"] as? String ?? "View")
8265
8395
  guard previous.type == type else { return false }
8396
+ if type == "Switch" {
8397
+ let props = node["props"] as? [String: Any] ?? [:]
8398
+ return (previous.view is CraftNativeSolidSwitch) == (props["ios_solid"] as? Bool == true)
8399
+ }
8266
8400
  if type == "Text" {
8267
8401
  let props = node["props"] as? [String: Any] ?? [:]
8268
8402
  return (previous.view is CraftNativeSelectableTextView) == (props["selectable"] as? Bool == true)
@@ -8614,6 +8748,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8614
8748
  pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
8615
8749
  pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
8616
8750
  case "Switch":
8751
+ if let toggle = result as? CraftNativeSolidSwitch {
8752
+ if let value = props["value"] as? Bool ?? props["checked"] as? Bool { toggle.setOn(value) }
8753
+ toggle.isEnabled = props["disabled"] as? Bool != true
8754
+ let track = props["trackColor"] as? [String: Any]
8755
+ toggle.onColor = track == nil ? color(style["color"]) : color(track?["true"])
8756
+ toggle.offColor = color(props["ios_backgroundColor"]) ?? color(track?["false"])
8757
+ toggle.thumbColor = color(props["thumbColor"])
8758
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
8759
+ break
8760
+ }
8617
8761
  let toggle = result as! UISwitch
8618
8762
  // A supplied value controls the switch; without one, UIKit owns
8619
8763
  // the state and a reconciliation must not erase a user's tap.
@@ -8928,6 +9072,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8928
9072
  case "Modal":
8929
9073
  return CraftNativeModalView()
8930
9074
  case "Switch":
9075
+ if props["ios_solid"] as? Bool == true {
9076
+ let toggle = CraftNativeSolidSwitch()
9077
+ toggle.addTarget(self, action: #selector(solidSwitchChanged(_:)), for: .valueChanged)
9078
+ return toggle
9079
+ }
8931
9080
  let toggle = UISwitch()
8932
9081
  toggle.addTarget(self, action: #selector(switchChanged(_:)), for: .valueChanged)
8933
9082
  return toggle
@@ -10470,6 +10619,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10470
10619
  // Assigning .button changes its legacy AX type into a button while
10471
10620
  // modern XCTest still identifies its UIKit class as a switch.
10472
10621
  if view is UITextView || view is UISwitch { return }
10622
+ if let toggle = view as? CraftNativeSolidSwitch { toggle.refreshAccessibility(); return }
10473
10623
  view.accessibilityTraits = traits
10474
10624
  }
10475
10625
 
@@ -10647,6 +10797,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10647
10797
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.values[index], "index": index]])
10648
10798
  }
10649
10799
 
10800
+ @objc private func solidSwitchChanged(_ sender: CraftNativeSolidSwitch) {
10801
+ guard sender.isEnabled, let handler = handlers[ObjectIdentifier(sender)] else { return }
10802
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
10803
+ }
10804
+
10650
10805
  @objc private func switchChanged(_ sender: UISwitch) {
10651
10806
  let id = ObjectIdentifier(sender)
10652
10807
  switchUserValues[id] = sender.isOn
@@ -280,12 +280,19 @@ final class CraftNativeSounds {
280
280
  var volume: Float
281
281
  var paused = false
282
282
  var interrupted = false
283
+ var pending: ((Result<Any, CraftNativeActionError>) -> Void)?
283
284
 
284
285
  init(player: AVQueuePlayer, volume: Float) {
285
286
  self.player = player
286
287
  self.volume = volume
287
288
  }
288
289
 
290
+ func settle(_ result: Result<Any, CraftNativeActionError>) {
291
+ let answer = pending
292
+ pending = nil
293
+ answer?(result)
294
+ }
295
+
289
296
  func end() {
290
297
  fade?.invalidate()
291
298
  fade = nil
@@ -357,33 +364,29 @@ final class CraftNativeSounds {
357
364
  let item = AVPlayerItem(url: url)
358
365
  let player = AVQueuePlayer()
359
366
  let sound = Sound(player: player, volume: volume)
367
+ sound.pending = answer
360
368
  sounds[id] = sound
361
- var answered = false
362
- let settle: (Result<Any, CraftNativeActionError>) -> Void = { result in
363
- guard !answered else { return }
364
- answered = true
365
- answer(result)
366
- }
367
369
  let failed: (String) -> Void = { [weak self, weak sound] message in
368
370
  guard let self, let sound, self.sounds[id] === sound else { return }
369
- self.remove(id)
370
- settle(.failure(CraftNativeActionError(code: "AUDIO_ERROR", message: message)))
371
+ self.remove(id, result: .failure(CraftNativeActionError(code: "AUDIO_ERROR", message: message)))
371
372
  self.emit?(["id": id, "event": "error", "message": message])
372
373
  }
373
374
  if loop {
374
375
  let looper = AVPlayerLooper(player: player, templateItem: item)
375
376
  sound.looper = looper
376
- sound.observation = looper.observe(\.status, options: [.initial, .new]) { looper, _ in
377
+ sound.observation = looper.observe(\.status, options: [.initial, .new]) { [weak self, weak sound] looper, _ in
377
378
  DispatchQueue.main.async {
378
- if looper.status == .ready { settle(.success(true)) }
379
+ guard let self, let sound, self.sounds[id] === sound else { return }
380
+ if looper.status == .ready { sound.settle(.success(true)) }
379
381
  else if looper.status == .failed { failed(looper.error?.localizedDescription ?? "The sound could not be played") }
380
382
  }
381
383
  }
382
384
  } else {
383
385
  player.insert(item, after: nil)
384
- sound.observation = item.observe(\.status, options: [.initial, .new]) { item, _ in
386
+ sound.observation = item.observe(\.status, options: [.initial, .new]) { [weak self, weak sound] item, _ in
385
387
  DispatchQueue.main.async {
386
- if item.status == .readyToPlay { settle(.success(true)) }
388
+ guard let self, let sound, self.sounds[id] === sound else { return }
389
+ if item.status == .readyToPlay { sound.settle(.success(true)) }
387
390
  else if item.status == .failed { failed(item.error?.localizedDescription ?? "The sound could not be played") }
388
391
  }
389
392
  }
@@ -451,10 +454,13 @@ final class CraftNativeSounds {
451
454
  for id in Array(sounds.keys) { remove(id) }
452
455
  }
453
456
 
454
- private func remove(_ id: String) {
457
+ private func remove(_ id: String, result: Result<Any, CraftNativeActionError> = .failure(CraftNativeActionError(code: "CANCELLED", message: "Audio playback was stopped or replaced"))) {
455
458
  guard let sound = sounds.removeValue(forKey: id) else { return }
456
459
  sound.end()
457
460
  Self.releaseSession()
461
+ // Settle after removing the old handle and releasing its resources so
462
+ // a completion can safely start another sound with the same id.
463
+ sound.settle(result)
458
464
  }
459
465
 
460
466
  /// Moves the volume in steps on the main run loop, a 30th of a second
@@ -6,7 +6,9 @@ protocol CraftVoiceRecorder: AnyObject {
6
6
  func record(forDuration duration: TimeInterval) -> Bool
7
7
  func stop()
8
8
  }
9
+ #if CRAFT_PERMISSION_MICROPHONE
9
10
  extension AVAudioRecorder: CraftVoiceRecorder {}
11
+ #endif
10
12
 
11
13
  /// The main thread owns requests; audio and encoder work belong to one serial
12
14
  /// queue. Cancellation invalidates a request immediately, even while AVFoundation
@@ -93,12 +95,22 @@ final class CraftVoiceRecording {
93
95
 
94
96
  init(audioQueue: DispatchQueue = DispatchQueue(label: "craft.voice-recording", qos: .userInitiated),
95
97
  startupTimeout: TimeInterval = 15,
96
- permission: @escaping (@escaping (Bool) -> Void) -> Void = { AVAudioSession.sharedInstance().requestRecordPermission($0) },
98
+ permission: @escaping (@escaping (Bool) -> Void) -> Void = { answer in
99
+ #if CRAFT_PERMISSION_MICROPHONE
100
+ AVAudioSession.sharedInstance().requestRecordPermission(answer)
101
+ #else
102
+ answer(false)
103
+ #endif
104
+ },
97
105
  makeRecorder: @escaping (URL) throws -> CraftVoiceRecorder = { file in
106
+ #if CRAFT_PERMISSION_MICROPHONE
98
107
  let session = AVAudioSession.sharedInstance()
99
108
  try session.setCategory(.record, mode: .measurement)
100
109
  try session.setActive(true)
101
110
  return try AVAudioRecorder(url: file, settings: [AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 32000])
111
+ #else
112
+ throw NSError(domain: "CraftVoiceRecording", code: 1, userInfo: [NSLocalizedDescriptionKey: "Microphone was not enabled when this app was built"])
113
+ #endif
102
114
  },
103
115
  deactivate: @escaping () -> Void = { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) }) {
104
116
  Self.prepareForLaunch()