craft-native 0.0.146 → 0.0.148

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.
@@ -226,7 +226,15 @@ private final class CraftNativeVideoView: UIView {
226
226
  private var player: AVPlayer?
227
227
  private var sourceURI = ""
228
228
  private var endObserver: NSObjectProtocol?
229
+ private var statusObservation: NSKeyValueObservation?
230
+ private var playingObservation: NSKeyValueObservation?
231
+ private var timeObserver: Any?
229
232
  private var loops = false
233
+ private var wasPlaying: Bool?
234
+ /// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
235
+ /// `onProgress`, `onLoad`, `onError`), and how an event reaches them.
236
+ var handlers: [String: String] = [:]
237
+ var emit: ((_ handler: String, _ nativeEvent: [String: Any]) -> Void)?
230
238
 
231
239
  init(host: UIViewController) {
232
240
  super.init(frame: .zero)
@@ -247,13 +255,35 @@ private final class CraftNativeVideoView: UIView {
247
255
  playerController.view.frame = bounds
248
256
  }
249
257
 
258
+ private func fire(_ event: String, _ nativeEvent: [String: Any] = [:]) {
259
+ guard let handler = handlers[event], !handler.isEmpty else { return }
260
+ emit?(handler, nativeEvent)
261
+ }
262
+
263
+ private func seconds(_ time: CMTime?) -> Double {
264
+ guard let time, time.isNumeric else { return 0 }
265
+ let value = CMTimeGetSeconds(time)
266
+ return value.isFinite ? value : 0
267
+ }
268
+
269
+ private func detachPlayer() {
270
+ if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
271
+ endObserver = nil
272
+ statusObservation?.invalidate()
273
+ statusObservation = nil
274
+ playingObservation?.invalidate()
275
+ playingObservation = nil
276
+ if let timeObserver, let player { player.removeTimeObserver(timeObserver) }
277
+ timeObserver = nil
278
+ wasPlaying = nil
279
+ player?.pause()
280
+ player = nil
281
+ }
282
+
250
283
  func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?) {
251
284
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
252
285
  if uri != sourceURI {
253
- if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
254
- endObserver = nil
255
- player?.pause()
256
- player = nil
286
+ detachPlayer()
257
287
  sourceURI = uri
258
288
  loops = false
259
289
  guard let url = URL(string: uri), !uri.isEmpty, url.scheme != nil else {
@@ -263,6 +293,7 @@ private final class CraftNativeVideoView: UIView {
263
293
  let next = AVPlayer(url: url)
264
294
  player = next
265
295
  playerController.player = next
296
+ observe(next)
266
297
  }
267
298
  guard let player else { return }
268
299
  player.isMuted = muted
@@ -270,28 +301,53 @@ private final class CraftNativeVideoView: UIView {
270
301
  playerController.videoGravity = resizeMode == "cover" ? .resizeAspectFill
271
302
  : resizeMode == "stretch" ? .resize
272
303
  : .resizeAspect
273
- if loops != loop {
274
- if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
275
- endObserver = nil
276
- loops = loop
277
- if loop, let item = player.currentItem {
278
- endObserver = NotificationCenter.default.addObserver(
279
- forName: .AVPlayerItemDidPlayToEndTime,
280
- object: item,
281
- queue: .main
282
- ) { [weak player] _ in
283
- player?.seek(to: .zero) { _ in player?.play() }
304
+ loops = loop
305
+ if paused || !autoplay { player.pause() } else { player.play() }
306
+ }
307
+
308
+ /// Load, play and pause, time, the end and failure, as the web's video
309
+ /// element reports them, so `@ended` and `@timeupdate` work as on the page.
310
+ private func observe(_ player: AVPlayer) {
311
+ if let item = player.currentItem {
312
+ statusObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in
313
+ DispatchQueue.main.async {
314
+ guard let self, self.player?.currentItem === item else { return }
315
+ switch item.status {
316
+ case .readyToPlay: self.fire("onLoad", ["duration": self.seconds(item.duration)])
317
+ case .failed: self.fire("onError", ["message": item.error?.localizedDescription ?? "The video could not be played"])
318
+ default: break
319
+ }
284
320
  }
285
321
  }
322
+ endObserver = NotificationCenter.default.addObserver(
323
+ forName: .AVPlayerItemDidPlayToEndTime,
324
+ object: item,
325
+ queue: .main
326
+ ) { [weak self, weak player] _ in
327
+ guard let self else { return }
328
+ self.fire("onEnd")
329
+ if self.loops { player?.seek(to: .zero) { _ in player?.play() } }
330
+ }
331
+ }
332
+ playingObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
333
+ DispatchQueue.main.async {
334
+ guard let self, self.player === player, player.timeControlStatus != .waitingToPlayAtSpecifiedRate else { return }
335
+ let playing = player.timeControlStatus == .playing
336
+ guard playing != self.wasPlaying else { return }
337
+ // Paused before it ever played is where it starts, not news.
338
+ if self.wasPlaying == nil && !playing { self.wasPlaying = false; return }
339
+ self.wasPlaying = playing
340
+ self.fire(playing ? "onPlay" : "onPause")
341
+ }
342
+ }
343
+ timeObserver = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: 600), queue: .main) { [weak self, weak player] time in
344
+ guard let self, let player, player.timeControlStatus == .playing else { return }
345
+ self.fire("onProgress", ["currentTime": self.seconds(time), "duration": self.seconds(player.currentItem?.duration)])
286
346
  }
287
- if paused || !autoplay { player.pause() } else { player.play() }
288
347
  }
289
348
 
290
349
  func stop() {
291
- player?.pause()
292
- if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
293
- endObserver = nil
294
- player = nil
350
+ detachPlayer()
295
351
  playerController.player = nil
296
352
  sourceURI = ""
297
353
  if playerController.parent != nil {
@@ -2035,6 +2091,35 @@ private final class CraftNativeLabel: UILabel {
2035
2091
  }
2036
2092
  }
2037
2093
 
2094
+ /// A one-line TextInput. Its style's padding is where the text and the
2095
+ /// placeholder start, as in a web input; UITextField has no padding of its own.
2096
+ final class CraftNativeTextField: UITextField {
2097
+ var padding = UIEdgeInsets.zero {
2098
+ didSet {
2099
+ guard padding != oldValue else { return }
2100
+ invalidateIntrinsicContentSize()
2101
+ setNeedsLayout()
2102
+ }
2103
+ }
2104
+
2105
+ override func textRect(forBounds bounds: CGRect) -> CGRect {
2106
+ super.textRect(forBounds: bounds.inset(by: padding))
2107
+ }
2108
+
2109
+ override func editingRect(forBounds bounds: CGRect) -> CGRect {
2110
+ super.editingRect(forBounds: bounds.inset(by: padding))
2111
+ }
2112
+
2113
+ override func placeholderRect(forBounds bounds: CGRect) -> CGRect {
2114
+ super.placeholderRect(forBounds: bounds.inset(by: padding))
2115
+ }
2116
+
2117
+ override var intrinsicContentSize: CGSize {
2118
+ let size = super.intrinsicContentSize
2119
+ return CGSize(width: size.width + padding.left + padding.right, height: size.height + padding.top + padding.bottom)
2120
+ }
2121
+ }
2122
+
2038
2123
  private final class CraftNativeFlexSpacer: UIView {}
2039
2124
 
2040
2125
  /// An SF Symbol leaf. Its natural size is the symbol's, or a 20 pt square
@@ -2121,7 +2206,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2121
2206
 
2122
2207
  private let config: CraftConfig
2123
2208
  private let routeName: String?
2124
- private let routeParams: [String: Any]
2209
+ private(set) var routeParams: [String: Any]
2125
2210
  private let jsContext = JSContext()!
2126
2211
  private let rootStack = CraftNativeFlowView()
2127
2212
  private var handlers: [ObjectIdentifier: String] = [:]
@@ -2165,6 +2250,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2165
2250
  private var svgOwners: [String: String] = [:]
2166
2251
  private let mutationDocument = CraftNativeMutationDocument()
2167
2252
  private let audio = CraftNativeAudio()
2253
+ /// `craft.audio`'s sounds and the streaming responses this screen reads.
2254
+ private let sounds = CraftNativeSounds()
2255
+ private let streams = CraftNativeEventStreams()
2256
+ /// A request a screen-owned module answers, by request token, and how to
2257
+ /// call it off when it times out or is cancelled.
2258
+ private var screenRequestCancellers: [String: () -> Void] = [:]
2259
+ /// Shown at least once: a later appearance is the screen coming back.
2260
+ private var hasAppeared = false
2261
+ /// Whether the script was last told the screen is on screen.
2262
+ private var reportedVisible = false
2168
2263
  private let capabilityScope = UUID().uuidString
2169
2264
  private var pendingCapabilityRequests = Set<String>()
2170
2265
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
@@ -2172,6 +2267,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2172
2267
  private var lifecycleObservers: [NSObjectProtocol] = []
2173
2268
  private var deepLinkListener: UUID?
2174
2269
  private var keyboardSafeAreaInset: CGFloat = 0
2270
+ /// Where the keyboard is on the screen while it is up, in screen coordinates.
2271
+ private var keyboardScreenFrame: CGRect?
2175
2272
  /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
2176
2273
  /// app's native screens: told of the first frame, of a first frame that
2177
2274
  /// could not be drawn, and of `craft.navigation.open(path)`.
@@ -2241,11 +2338,45 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2241
2338
  refreshTraitDefaults()
2242
2339
  }
2243
2340
 
2341
+ override func viewDidAppear(_ animated: Bool) {
2342
+ super.viewDidAppear(animated)
2343
+ screenVisibilityChanged(true)
2344
+ }
2345
+
2244
2346
  override func viewDidDisappear(_ animated: Bool) {
2245
2347
  super.viewDidDisappear(animated)
2246
2348
  if isMovingFromParent || navigationController?.viewControllers.contains(where: { $0 === self }) == false {
2247
2349
  disposeRuntime()
2350
+ } else {
2351
+ screenVisibilityChanged(false)
2352
+ }
2353
+ }
2354
+
2355
+ /// The screen came on screen or went off it: a screen pushed over it was
2356
+ /// popped, or the hybrid shell switched to or away from its tab (which
2357
+ /// hides a stack rather than moving it, so UIKit says nothing). The first
2358
+ /// appearance is the screen's mount, not news; every later one is
2359
+ /// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
2360
+ func screenVisibilityChanged(_ visible: Bool) {
2361
+ guard !runtimeDisposed, visible != reportedVisible else { return }
2362
+ reportedVisible = visible
2363
+ if visible, !hasAppeared {
2364
+ hasAppeared = true
2365
+ return
2248
2366
  }
2367
+ send(type: "SCREEN_FOCUS", payload: ["focused": visible])
2368
+ }
2369
+
2370
+ /// New params for the screen already showing a path: opened again with
2371
+ /// another query (`/m/messages?person=8`), or a tab's root opened with
2372
+ /// one (`/m/store?coach=5`). The script's `craft.route.params` changes
2373
+ /// and it hears `ROUTE_PARAMS`; before the script has run, it simply
2374
+ /// starts with them.
2375
+ func receiveRouteParams(_ params: [String: Any]) {
2376
+ routeParams = params
2377
+ guard isViewLoaded, !runtimeDisposed else { return }
2378
+ jsContext.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
2379
+ send(type: "ROUTE_PARAMS", payload: ["params": params])
2249
2380
  }
2250
2381
 
2251
2382
  private func disposeRuntime() {
@@ -2253,6 +2384,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2253
2384
  runtimeDisposed = true
2254
2385
  jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
2255
2386
  audio.close()
2387
+ sounds.close()
2388
+ streams.close()
2389
+ screenRequestCancellers.values.forEach { $0() }
2390
+ screenRequestCancellers.removeAll()
2256
2391
  }
2257
2392
 
2258
2393
  private func refreshTraitDefaults() {
@@ -2950,6 +3085,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2950
3085
  guard let name = notification.userInfo?["name"] as? String else { return }
2951
3086
  self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
2952
3087
  })
3088
+ if config.enablePushNotifications {
3089
+ // A new device token (iOS hands one out after a reinstall or a
3090
+ // restore): every screen's `craft.push.onToken` hears it.
3091
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
3092
+ forName: .craftPushToken,
3093
+ object: nil,
3094
+ queue: .main
3095
+ ) { [weak self] notification in
3096
+ guard let token = notification.object as? String else { return }
3097
+ self?.send(type: "PUSH_TOKEN", payload: ["token": token])
3098
+ })
3099
+ }
3100
+ // The coordinator's location events (a watch, a recording's fixes):
3101
+ // to this screen's script as the page's own CustomEvents.
3102
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
3103
+ forName: CraftWebServices.event,
3104
+ object: nil,
3105
+ queue: .main
3106
+ ) { [weak self] notification in
3107
+ guard let name = notification.userInfo?["event"] as? String else { return }
3108
+ self?.send(type: "CRAFT_EVENT", payload: ["event": name, "detail": notification.userInfo?["detail"] ?? [:]])
3109
+ })
3110
+ sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
3111
+ streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
2953
3112
  sendAppState(CraftNativeActions.currentAppState())
2954
3113
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
2955
3114
  guard let self = self, self.navigationController?.topViewController === self else { return }
@@ -2961,24 +3120,74 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2961
3120
 
2962
3121
  private func applyKeyboardSafeArea(_ notification: Notification, hidden: Bool = false) {
2963
3122
  let inset: CGFloat
3123
+ let keyboardFrame: CGRect?
2964
3124
  if hidden {
2965
3125
  inset = 0
3126
+ keyboardFrame = nil
2966
3127
  } else if let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue {
2967
- let keyboardFrame = view.convert(frame.cgRectValue, from: nil)
2968
- inset = max(0, view.bounds.intersection(keyboardFrame).height - view.safeAreaInsets.bottom)
3128
+ keyboardFrame = frame.cgRectValue
3129
+ let local = view.convert(frame.cgRectValue, from: nil)
3130
+ // The safe area without the keyboard's own share, which an earlier
3131
+ // change of the keyboard's height added to it.
3132
+ inset = max(0, view.bounds.intersection(local).height - (view.safeAreaInsets.bottom - additionalSafeAreaInsets.bottom))
2969
3133
  } else {
2970
3134
  return
2971
3135
  }
2972
- guard abs(inset - keyboardSafeAreaInset) > 0.5 else { return }
2973
- keyboardSafeAreaInset = inset
2974
3136
  let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
2975
3137
  let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
3138
+ let changed = abs(inset - keyboardSafeAreaInset) > 0.5
3139
+ keyboardSafeAreaInset = inset
3140
+ keyboardScreenFrame = keyboardFrame
2976
3141
  UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
2977
- self.additionalSafeAreaInsets.bottom = inset
2978
- self.view.layoutIfNeeded()
3142
+ if changed {
3143
+ self.additionalSafeAreaInsets.bottom = inset
3144
+ self.view.layoutIfNeeded()
3145
+ }
3146
+ self.insetScrollViewsForKeyboard()
3147
+ self.scrollFocusedInputIntoView(animated: false)
3148
+ }
3149
+ }
3150
+
3151
+ /// A scroll view the keyboard still covers part of (one that does not end
3152
+ /// at the screen's bottom edge, or a keyboard taller than the safe area
3153
+ /// took account of) insets its content by that much, so everything in it
3154
+ /// can be scrolled above the keyboard. Internal for XCTest.
3155
+ func insetScrollViewsForKeyboard() {
3156
+ var scrolls: [UIScrollView] = []
3157
+ func collect(_ node: RenderedNode?) {
3158
+ guard let node else { return }
3159
+ if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical { scrolls.append(scroll) }
3160
+ node.children.forEach(collect)
3161
+ }
3162
+ collect(renderedRoot)
3163
+ for scroll in scrolls {
3164
+ var overlap: CGFloat = 0
3165
+ if let keyboard = keyboardScreenFrame, let window = scroll.window {
3166
+ let frame = scroll.convert(scroll.bounds, to: window)
3167
+ overlap = max(0, frame.maxY - window.convert(keyboard, from: nil).minY)
3168
+ }
3169
+ if abs(scroll.contentInset.bottom - overlap) > 0.5 { scroll.contentInset.bottom = overlap }
3170
+ if abs(scroll.verticalScrollIndicatorInsets.bottom - overlap) > 0.5 { scroll.verticalScrollIndicatorInsets.bottom = overlap }
2979
3171
  }
2980
3172
  }
2981
3173
 
3174
+ /// The focused field, scrolled into the part of its scroll view the
3175
+ /// keyboard leaves, with a little room around it.
3176
+ func scrollFocusedInputIntoView(animated: Bool) {
3177
+ guard let input = firstResponder(in: rootStack) else { return }
3178
+ scrollIntoView(input, animated: animated)
3179
+ }
3180
+
3181
+ /// `input`, scrolled into the visible part of the scroll view it is in.
3182
+ /// Internal for XCTest.
3183
+ func scrollIntoView(_ input: UIView, animated: Bool) {
3184
+ var ancestor = input.superview
3185
+ while let current = ancestor, !(current is CraftNativeScrollView) { ancestor = current.superview }
3186
+ guard let scroll = ancestor as? CraftNativeScrollView, scroll.isVertical else { return }
3187
+ let rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
3188
+ scroll.scrollRectToVisible(rect, animated: animated)
3189
+ }
3190
+
2982
3191
  private func sendAppState(_ state: String) {
2983
3192
  guard navigationController?.topViewController === self else { return }
2984
3193
  send(type: "APP_STATE", payload: ["state": state])
@@ -3073,6 +3282,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3073
3282
  let deadline = DispatchWorkItem { [weak self] in
3074
3283
  guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
3075
3284
  self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)
3285
+ self.screenRequestCancellers.removeValue(forKey: requestToken)?()
3076
3286
  CraftNativeActions.cancel(requestToken: requestToken)
3077
3287
  self.send(type: "API_ERROR", payload: [
3078
3288
  "version": craftNativeCapabilityProtocolVersion,
@@ -3086,16 +3296,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3086
3296
  deadline: .now() + .milliseconds(craftNativeCapabilityTimeoutMilliseconds),
3087
3297
  execute: deadline
3088
3298
  )
3089
- CraftNativeActions.perform(
3090
- requestToken: requestToken,
3091
- version: payload["version"] as? Int ?? craftNativeCapabilityProtocolVersion,
3092
- module: payload["module"] as? String ?? "",
3093
- method: payload["method"] as? String ?? "",
3094
- args: args,
3095
- config: config
3096
- ) { [weak self] answer in
3299
+ let module = payload["module"] as? String ?? ""
3300
+ let method = payload["method"] as? String ?? ""
3301
+ let reply: (Result<Any, CraftNativeActionError>) -> Void = { [weak self] answer in
3097
3302
  guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
3098
3303
  self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
3304
+ self.screenRequestCancellers.removeValue(forKey: requestToken)
3099
3305
  switch answer {
3100
3306
  case .success(let data):
3101
3307
  self.send(type: "API_RESPONSE", payload: [
@@ -3112,11 +3318,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3112
3318
  ], correlationId: id)
3113
3319
  }
3114
3320
  }
3321
+ if performOnScreen(module: module, method: method, args: args, requestToken: requestToken, reply: reply) { return }
3322
+ CraftNativeActions.perform(
3323
+ requestToken: requestToken,
3324
+ version: payload["version"] as? Int ?? craftNativeCapabilityProtocolVersion,
3325
+ module: module,
3326
+ method: method,
3327
+ args: args,
3328
+ config: config,
3329
+ completion: reply
3330
+ )
3115
3331
  case "API_CANCEL":
3116
3332
  guard let id = payload["requestId"] as? String else { return }
3117
3333
  let requestToken = "\(capabilityScope)/\(id)"
3118
3334
  pendingCapabilityRequests.remove(requestToken)
3119
3335
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
3336
+ screenRequestCancellers.removeValue(forKey: requestToken)?()
3120
3337
  CraftNativeActions.cancel(requestToken: requestToken)
3121
3338
  case "AUDIO":
3122
3339
  audio.handle(payload)
@@ -3125,6 +3342,59 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3125
3342
  }
3126
3343
  }
3127
3344
 
3345
+ /// The modules whose work belongs to this screen and ends with it:
3346
+ /// `craft.audio`'s sounds and streaming `fetch`. False for any other
3347
+ /// module, which CraftNativeActions serves.
3348
+ private func performOnScreen(
3349
+ module: String,
3350
+ method: String,
3351
+ args: [Any],
3352
+ requestToken: String,
3353
+ reply: @escaping (Result<Any, CraftNativeActionError>) -> Void
3354
+ ) -> Bool {
3355
+ func number(_ index: Int, _ key: String? = nil) -> Double {
3356
+ guard args.count > index else { return 0 }
3357
+ if let key { return ((args[index] as? [String: Any])?[key] as? NSNumber)?.doubleValue ?? 0 }
3358
+ return (args[index] as? NSNumber)?.doubleValue ?? 0
3359
+ }
3360
+ let id = args.first as? String ?? ""
3361
+ switch (module, method) {
3362
+ case ("Network", "stream"):
3363
+ let options = args.first as? [String: Any] ?? [:]
3364
+ let streamId = options["id"] as? String ?? ""
3365
+ screenRequestCancellers[requestToken] = { [weak self] in self?.streams.cancel(id: streamId) }
3366
+ streams.open(id: streamId, options: options, answer: reply)
3367
+ case ("Network", "cancelStream"):
3368
+ streams.cancel(id: id)
3369
+ reply(.success(true))
3370
+ case ("Audio", "play"):
3371
+ guard args.count > 1, let address = args[1] as? String, let url = URL(string: address), url.scheme != nil, !id.isEmpty else {
3372
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "audio.play needs a URL")))
3373
+ return true
3374
+ }
3375
+ screenRequestCancellers[requestToken] = { [weak self] in self?.sounds.stop(id: id, fadeOut: 0) }
3376
+ sounds.play(id: id, url: url, options: args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:], answer: reply)
3377
+ case ("Audio", "pause"):
3378
+ sounds.pause(id: id)
3379
+ reply(.success(true))
3380
+ case ("Audio", "resume"):
3381
+ sounds.resume(id: id)
3382
+ reply(.success(true))
3383
+ case ("Audio", "setVolume"):
3384
+ sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)
3385
+ reply(.success(true))
3386
+ case ("Audio", "stop"):
3387
+ sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)
3388
+ reply(.success(true))
3389
+ case ("Audio", "stopAll"):
3390
+ sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)
3391
+ reply(.success(true))
3392
+ default:
3393
+ return false
3394
+ }
3395
+ return true
3396
+ }
3397
+
3128
3398
  /// Runs script in the screen's JavaScript context. Internal for
3129
3399
  /// simulator-hosted XCTest of the host APIs.
3130
3400
  @discardableResult
@@ -3306,6 +3576,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3306
3576
  textMaxLengths.removeValue(forKey: fieldId)
3307
3577
  }
3308
3578
  field.delegate = self
3579
+ (field as? CraftNativeTextField)?.padding = paddingInsets(style)
3309
3580
  field.textColor = color(style["color"]) ?? .label
3310
3581
  field.font = textFont(style, default: .systemFont(ofSize: 16))
3311
3582
  field.textAlignment = textAlignment(style["textAlign"])
@@ -3323,6 +3594,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3323
3594
  if field.autocapitalizationType != desiredCapitalization { field.autocapitalizationType = desiredCapitalization }
3324
3595
  let desiredEnabled = props["editable"] as? Bool != false
3325
3596
  if field.isEnabled != desiredEnabled { field.isEnabled = desiredEnabled }
3597
+ applyAutofill(to: field, props: props)
3326
3598
  if let value = props["value"] as? String {
3327
3599
  updateField(field, value: value)
3328
3600
  } else if previous?.view !== field {
@@ -3356,6 +3628,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3356
3628
  }
3357
3629
  picker.isUserInteractionEnabled = props["disabled"] as? Bool != true
3358
3630
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
3631
+ case "DatePicker":
3632
+ let picker = result as! UIDatePicker
3633
+ let mode = Self.datePickerMode(props["mode"])
3634
+ if picker.datePickerMode != mode { picker.datePickerMode = mode }
3635
+ let display: UIDatePickerStyle = props["display"] as? String == "inline" ? .inline
3636
+ : props["display"] as? String == "wheels" || props["display"] as? String == "spinner" ? .wheels
3637
+ : .compact
3638
+ if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
3639
+ // UIKit takes an interval that divides an hour, and ignores others.
3640
+ let interval = (props["minuteInterval"] as? NSNumber)?.intValue ?? 1
3641
+ picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
3642
+ picker.minimumDate = Self.pickerDate(props["minimumDate"])
3643
+ picker.maximumDate = Self.pickerDate(props["maximumDate"])
3644
+ if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
3645
+ picker.setDate(date, animated: false)
3646
+ }
3647
+ picker.tintColor = color(props["color"]) ?? color(style["color"])
3648
+ picker.isEnabled = props["disabled"] as? Bool != true
3649
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
3359
3650
  case "Modal":
3360
3651
  let modal = result as! CraftNativeModalView
3361
3652
  modal.transparent = props["transparent"] as? Bool == true
@@ -3474,8 +3765,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3474
3765
  case "Video":
3475
3766
  let video = result as! CraftNativeVideoView
3476
3767
  let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
3768
+ video.handlers = events
3769
+ video.emit = { [weak self] handler, nativeEvent in
3770
+ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": nativeEvent])
3771
+ }
3477
3772
  video.configure(
3478
- source: props["source"],
3773
+ source: props["source"] ?? props["src"],
3479
3774
  autoplay: autoplay,
3480
3775
  paused: (props["paused"] as? Bool) == true,
3481
3776
  loop: (props["loop"] as? Bool) == true,
@@ -3533,7 +3828,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3533
3828
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
3534
3829
  }
3535
3830
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
3536
- if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
3831
+ if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
3537
3832
  let disabled = props["disabled"] as? Bool == true
3538
3833
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
3539
3834
  updatePressFeedback(
@@ -3563,7 +3858,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3563
3858
  textView.delegate = self
3564
3859
  return textView
3565
3860
  }
3566
- let field = UITextField()
3861
+ let field = CraftNativeTextField()
3567
3862
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
3568
3863
  field.addTarget(self, action: #selector(textFocused(_:)), for: .editingDidBegin)
3569
3864
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
@@ -3574,6 +3869,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3574
3869
  picker.dataSource = self
3575
3870
  picker.delegate = self
3576
3871
  return picker
3872
+ case "DatePicker":
3873
+ let picker = UIDatePicker()
3874
+ picker.preferredDatePickerStyle = .compact
3875
+ picker.addTarget(self, action: #selector(datePicked(_:)), for: .valueChanged)
3876
+ return picker
3577
3877
  case "Modal":
3578
3878
  return CraftNativeModalView()
3579
3879
  case "Switch":
@@ -4005,6 +4305,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4005
4305
  textView.autocapitalizationType = capitalizationType(props["autoCapitalize"])
4006
4306
  textView.isSecureTextEntry = props["secureTextEntry"] as? Bool == true
4007
4307
  textView.isEditable = props["editable"] as? Bool != false
4308
+ applyAutofill(to: textView, props: props)
4309
+ // The style's padding is where the text starts, as in a web textarea.
4310
+ let padding = paddingInsets(style)
4311
+ if padding != .zero {
4312
+ if textView.textContainerInset != padding { textView.textContainerInset = padding }
4313
+ textView.textContainer.lineFragmentPadding = 0
4314
+ }
4008
4315
  textView.isScrollEnabled = false
4009
4316
  if let numberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue, numberOfLines > 0 {
4010
4317
  textView.textContainer.maximumNumberOfLines = numberOfLines
@@ -4318,6 +4625,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4318
4625
  }
4319
4626
  }
4320
4627
 
4628
+ /// An image the screen downloads: https anywhere, and http only from this
4629
+ /// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
4630
+ /// server the app's ATS exception already allows. Internal for XCTest.
4631
+ static func loadsImage(from url: URL) -> Bool {
4632
+ let scheme = url.scheme?.lowercased()
4633
+ if scheme == "https" { return true }
4634
+ guard scheme == "http", let host = url.host?.lowercased() else { return false }
4635
+ let bare = host.hasPrefix("[") && host.hasSuffix("]") ? String(host.dropFirst().dropLast()) : host
4636
+ return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
4637
+ }
4638
+
4321
4639
  private func updateImage(_ view: UIImageView, source: Any?) {
4322
4640
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
4323
4641
  let id = ObjectIdentifier(view)
@@ -4348,7 +4666,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4348
4666
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
4349
4667
  return
4350
4668
  }
4351
- if let url = URL(string: uri), url.scheme == "https" {
4669
+ if let url = URL(string: uri), Self.loadsImage(from: url) {
4352
4670
  let task = URLSession.shared.dataTask(with: url) { [weak self, weak view] data, _, _ in
4353
4671
  guard let self, let view else { return }
4354
4672
  DispatchQueue.main.async {
@@ -4471,6 +4789,31 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4471
4789
  }
4472
4790
  }
4473
4791
 
4792
+ /// Return on a `returnKeyType="next"` field the screen does not handle
4793
+ /// itself (no `@submit`) goes to the next field down the screen, keeping
4794
+ /// the keyboard up. Every other Return ends editing and fires `@submit`.
4795
+ func textFieldShouldReturn(_ textField: UITextField) -> Bool {
4796
+ guard textField.returnKeyType == .next, submitHandlers[ObjectIdentifier(textField)] == nil,
4797
+ let next = inputAfter(textField) else { return true }
4798
+ next.becomeFirstResponder()
4799
+ return false
4800
+ }
4801
+
4802
+ /// The editable field after `input` in the screen's document order.
4803
+ /// Internal for XCTest.
4804
+ func inputAfter(_ input: UIView) -> UIView? {
4805
+ var inputs: [UIView] = []
4806
+ func collect(_ node: RenderedNode?) {
4807
+ guard let node else { return }
4808
+ if let field = node.view as? UITextField, field.isEnabled, !field.isHidden { inputs.append(field) }
4809
+ if let textView = node.view as? UITextView, textView.isEditable, !textView.isHidden { inputs.append(textView) }
4810
+ node.children.forEach(collect)
4811
+ }
4812
+ collect(renderedRoot)
4813
+ guard let index = inputs.firstIndex(where: { $0 === input }), index + 1 < inputs.count else { return nil }
4814
+ return inputs[index + 1]
4815
+ }
4816
+
4474
4817
  func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
4475
4818
  guard let maxLength = textMaxLengths[ObjectIdentifier(textField)],
4476
4819
  let current = textField.text,
@@ -4499,6 +4842,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4499
4842
 
4500
4843
  func textViewDidBeginEditing(_ textView: UITextView) {
4501
4844
  lastFocusedInput = textView
4845
+ if keyboardScreenFrame != nil { DispatchQueue.main.async { self.scrollFocusedInputIntoView(animated: true) } }
4502
4846
  guard let handler = focusHandlers[ObjectIdentifier(textView)] else { return }
4503
4847
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": textView.text ?? ""]])
4504
4848
  }
@@ -4539,6 +4883,66 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4539
4883
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
4540
4884
  }
4541
4885
 
4886
+ /// `@change` on a DatePicker: the value as `<input type="date|time|
4887
+ /// datetime-local">` writes it, in local time, with the moment as ISO and
4888
+ /// milliseconds for code that wants those.
4889
+ @objc private func datePicked(_ sender: UIDatePicker) {
4890
+ guard let handler = handlers[ObjectIdentifier(sender)] else { return }
4891
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender.date, mode: sender.datePickerMode)])
4892
+ }
4893
+
4894
+ static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
4895
+ let local = DateFormatter()
4896
+ local.locale = Locale(identifier: "en_US_POSIX")
4897
+ local.calendar = Calendar(identifier: .gregorian)
4898
+ local.timeZone = .current
4899
+ local.dateFormat = mode == .date ? "yyyy-MM-dd" : mode == .time ? "HH:mm" : "yyyy-MM-dd'T'HH:mm"
4900
+ let iso = ISO8601DateFormatter()
4901
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
4902
+ return [
4903
+ "value": local.string(from: date),
4904
+ "iso": iso.string(from: date),
4905
+ "timestamp": (date.timeIntervalSince1970 * 1000).rounded(),
4906
+ ]
4907
+ }
4908
+
4909
+ static func datePickerMode(_ value: Any?) -> UIDatePicker.Mode {
4910
+ switch value as? String {
4911
+ case "time": return .time
4912
+ case "datetime", "dateAndTime", "datetime-local": return .dateAndTime
4913
+ case "countdown": return .countDownTimer
4914
+ default: return .date
4915
+ }
4916
+ }
4917
+
4918
+ /// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
4919
+ /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
4920
+ /// milliseconds since 1970. Nil for anything else.
4921
+ static func pickerDate(_ value: Any?) -> Date? {
4922
+ if let number = value as? NSNumber, !(value is Bool) { return Date(timeIntervalSince1970: number.doubleValue / 1000) }
4923
+ guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
4924
+ let local = DateFormatter()
4925
+ local.locale = Locale(identifier: "en_US_POSIX")
4926
+ local.calendar = Calendar(identifier: .gregorian)
4927
+ local.timeZone = .current
4928
+ for format in ["HH:mm", "HH:mm:ss"] {
4929
+ local.dateFormat = format
4930
+ if let time = local.date(from: text) {
4931
+ let parts = Calendar.current.dateComponents([.hour, .minute, .second], from: time)
4932
+ return Calendar.current.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: parts.second ?? 0, of: Date())
4933
+ }
4934
+ }
4935
+ for format in ["yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS"] {
4936
+ local.dateFormat = format
4937
+ if let date = local.date(from: text) { return date }
4938
+ }
4939
+ let iso = ISO8601DateFormatter()
4940
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
4941
+ if let date = iso.date(from: text) { return date }
4942
+ iso.formatOptions = [.withInternetDateTime]
4943
+ return iso.date(from: text)
4944
+ }
4945
+
4542
4946
  @objc private func sliderFinished(_ sender: UISlider) {
4543
4947
  guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
4544
4948
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
@@ -4567,6 +4971,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4567
4971
 
4568
4972
  @objc private func textFocused(_ sender: UITextField) {
4569
4973
  lastFocusedInput = sender
4974
+ // Moving between fields does not move the keyboard, so nothing else
4975
+ // brings the next one up from under it.
4976
+ if keyboardScreenFrame != nil { DispatchQueue.main.async { self.scrollFocusedInputIntoView(animated: true) } }
4570
4977
  guard let handler = focusHandlers[ObjectIdentifier(sender)] else { return }
4571
4978
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
4572
4979
  }
@@ -4677,10 +5084,66 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4677
5084
  case "url": return .URL
4678
5085
  case "web-search": return .webSearch
4679
5086
  case "visible-password": return .asciiCapable
5087
+ case "ascii-capable": return .asciiCapable
5088
+ case "numbers-and-punctuation": return .numbersAndPunctuation
5089
+ case "name-phone-pad": return .namePhonePad
5090
+ case "twitter": return .twitter
4680
5091
  default: return .default
4681
5092
  }
4682
5093
  }
4683
5094
 
5095
+ /// What the field holds, for the system's autofill: `textContentType` in
5096
+ /// UITextContentType's names (`username`, `password`, `newPassword`,
5097
+ /// `oneTimeCode`...), or `autoComplete` in HTML's (`current-password`,
5098
+ /// `one-time-code`, `email`). A password field without it gets no
5099
+ /// Passwords suggestion above the keyboard.
5100
+ static func textContentType(_ props: [String: Any]) -> UITextContentType? {
5101
+ let raw = (props["textContentType"] as? String) ?? (props["autoComplete"] as? String) ?? (props["autocomplete"] as? String)
5102
+ guard let raw, !raw.isEmpty else { return nil }
5103
+ let key = raw.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: "_", with: "").lowercased()
5104
+ switch key {
5105
+ case "username": return .username
5106
+ case "nickname": return .nickname
5107
+ case "password", "currentpassword": return .password
5108
+ case "newpassword": return .newPassword
5109
+ case "onetimecode": return .oneTimeCode
5110
+ case "emailaddress", "email": return .emailAddress
5111
+ case "telephonenumber", "tel", "phone": return .telephoneNumber
5112
+ case "name": return .name
5113
+ case "givenname", "firstname": return .givenName
5114
+ case "middlename", "additionalname": return .middleName
5115
+ case "familyname", "lastname": return .familyName
5116
+ case "nameprefix", "honorificprefix": return .namePrefix
5117
+ case "namesuffix", "honorificsuffix": return .nameSuffix
5118
+ case "organizationname", "organization": return .organizationName
5119
+ case "jobtitle", "organizationtitle": return .jobTitle
5120
+ case "url": return .URL
5121
+ case "fullstreetaddress", "streetaddress": return .fullStreetAddress
5122
+ case "streetaddressline1", "addressline1": return .streetAddressLine1
5123
+ case "streetaddressline2", "addressline2": return .streetAddressLine2
5124
+ case "addresscity", "addresslevel2": return .addressCity
5125
+ case "addressstate", "addresslevel1": return .addressState
5126
+ case "addresscityandstate": return .addressCityAndState
5127
+ case "postalcode": return .postalCode
5128
+ case "countryname", "country": return .countryName
5129
+ case "location": return .location
5130
+ case "creditcardnumber", "ccnumber": return .creditCardNumber
5131
+ default: return nil
5132
+ }
5133
+ }
5134
+
5135
+ private func applyAutofill(to input: UITextInputTraits & UIView, props: [String: Any]) {
5136
+ let type = Self.textContentType(props)
5137
+ let rules = (props["passwordRules"] as? String).flatMap { $0.isEmpty ? nil : UITextInputPasswordRules(descriptor: $0) }
5138
+ if let field = input as? UITextField {
5139
+ if field.textContentType != type { field.textContentType = type }
5140
+ if field.passwordRules?.passwordRulesDescriptor != rules?.passwordRulesDescriptor { field.passwordRules = rules }
5141
+ } else if let textView = input as? UITextView {
5142
+ if textView.textContentType != type { textView.textContentType = type }
5143
+ if textView.passwordRules?.passwordRulesDescriptor != rules?.passwordRulesDescriptor { textView.passwordRules = rules }
5144
+ }
5145
+ }
5146
+
4684
5147
  private func returnKeyType(_ value: Any?) -> UIReturnKeyType {
4685
5148
  switch value as? String {
4686
5149
  case "done": return .done
@@ -4688,6 +5151,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4688
5151
  case "next": return .next
4689
5152
  case "search": return .search
4690
5153
  case "send": return .send
5154
+ case "join": return .join
5155
+ case "route": return .route
5156
+ case "continue": return .continue
5157
+ case "emergency-call": return .emergencyCall
4691
5158
  default: return .default
4692
5159
  }
4693
5160
  }