craft-native 0.0.387 → 0.0.392

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.387";
3050
+ var version = "0.0.392";
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.387";
378
+ var version = "0.0.392";
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.387";
120
+ var version = "0.0.392";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -1,6 +1,13 @@
1
1
  import Foundation
2
2
  import SwiftUI
3
3
  import UIKit
4
+
5
+ /// Includes deceleration, which runs in default mode after tracking ends.
6
+ func craftHybridHasActiveScroll(in view: UIView) -> Bool {
7
+ if let scroll = view as? UIScrollView,
8
+ scroll.isTracking || scroll.isDragging || scroll.isDecelerating { return true }
9
+ return view.subviews.contains { craftHybridHasActiveScroll(in: $0) }
10
+ }
4
11
  import WebKit
5
12
  import Combine
6
13
 
@@ -1261,9 +1268,14 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1261
1268
  item.accessibilityLabel = tab.accessibilityLabel ?? tab.displayTitle
1262
1269
  }
1263
1270
  if let committed = stacks[currentTab], controllers.contains(where: { $0 === committed }), selectedViewController !== committed {
1264
- UIView.performWithoutAnimation {
1265
- selectedViewController = committed
1266
- view.layoutIfNeeded()
1271
+ // UIKit owns the Liquid Glass selection pill. Suppressing this
1272
+ // transaction made a completed page swipe snap its indicator.
1273
+ // Keep native selection animation and honour Reduce Motion.
1274
+ let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.22
1275
+ UIView.animate(withDuration: duration, delay: 0,
1276
+ options: [.beginFromCurrentState, .allowUserInteraction]) {
1277
+ self.selectedViewController = committed
1278
+ self.tabBar.layoutIfNeeded()
1267
1279
  }
1268
1280
  }
1269
1281
  tabBar.tintColor = chrome.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
@@ -1950,6 +1962,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1950
1962
  guard self.currentTab == owner, self.view.window != nil else { return }
1951
1963
  let quietFor = neighbour ? 0.3 : 1.0
1952
1964
  guard self.activeTabSwipeTarget == nil, !self.tabSwipeSettling,
1965
+ !craftHybridHasActiveScroll(in: self.view),
1953
1966
  !self.stacks.values.contains(where: { $0.transitionCoordinator != nil }),
1954
1967
  CACurrentMediaTime() - self.lastNavigation >= quietFor else {
1955
1968
  self.prepareAdjacentNativeTabs()
@@ -272,19 +272,19 @@ enum CraftNativeActions {
272
272
  // A person picks, so the bridge never times it out.
273
273
  let options = args.first as? [String: Any] ?? [:]
274
274
  DispatchQueue.main.async {
275
- CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { completeFile(requestToken, $0, completion) }
275
+ CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera, requestToken: requestToken, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
276
276
  }
277
277
  return
278
278
  case ("Files", "pickVideo"):
279
279
  let options = args.first as? [String: Any] ?? [:]
280
280
  DispatchQueue.main.async {
281
- CraftNativeFiles.pickVideo(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
281
+ CraftNativeFiles.pickVideo(options, requestToken: requestToken, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
282
282
  }
283
283
  return
284
284
  case ("Files", "pickFile"):
285
285
  let options = args.first as? [String: Any] ?? [:]
286
286
  DispatchQueue.main.async {
287
- CraftNativeFiles.pickFile(options, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
287
+ CraftNativeFiles.pickFile(options, requestToken: requestToken, isCancelled: { isCancelled(requestToken) }) { completeFile(requestToken, $0, completion) }
288
288
  }
289
289
  return
290
290
  case ("Files", "download"):
@@ -719,6 +719,7 @@ enum CraftNativeActions {
719
719
  context?.invalidate()
720
720
  #endif
721
721
  task?.cancel()
722
+ CraftNativeFiles.cancelPicker(requestToken: requestToken)
722
723
  CraftNativePasskeys.shared.cancel(requestToken: requestToken)
723
724
  }
724
725
 
@@ -121,7 +121,8 @@ enum CraftNativeFiles {
121
121
  /// larger than `maxPixels` on its longest side, upright, with no metadata
122
122
  /// (no location) left in it. Answers the file, or null when the person
123
123
  /// closed the picker. Main thread.
124
- static func pickImage(_ options: [String: Any], cameraEnabled: Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
124
+ static func pickImage(_ options: [String: Any], cameraEnabled: Bool, requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
125
+ guard !isCancelled() else { done(.failure(importCancelled())); return }
125
126
  let maxPixels = craftNativeNumber(options["maxPixels"])
126
127
  .map { $0.doubleValue }
127
128
  .flatMap { value in
@@ -136,13 +137,6 @@ enum CraftNativeFiles {
136
137
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
137
138
  return
138
139
  }
139
- let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
140
- DispatchQueue.main.async {
141
- activePicker = nil
142
- done(result)
143
- }
144
- }
145
-
146
140
  if source == "camera" {
147
141
  #if CRAFT_PERMISSION_CAMERA
148
142
  guard cameraEnabled else {
@@ -153,15 +147,9 @@ enum CraftNativeFiles {
153
147
  done(.failure(CraftNativeActionError(code: "UNAVAILABLE", message: "This device has no camera")))
154
148
  return
155
149
  }
156
- let delegate = CameraDelegate(maxPixels: maxPixels, quality: quality, done: finish)
157
150
  let picker = UIImagePickerController()
158
151
  picker.sourceType = .camera
159
- picker.delegate = delegate
160
- activePicker = delegate
161
- if !CraftPresenter.present(picker, from: nil) {
162
- activePicker = nil
163
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
164
- }
152
+ presentCamera(picker, maxPixels: maxPixels, quality: quality, requestToken: requestToken, isCancelled: isCancelled, done: done)
165
153
  #else
166
154
  done(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "The camera was not enabled when this app was built")))
167
155
  #endif
@@ -177,13 +165,13 @@ enum CraftNativeFiles {
177
165
  configuration.selectionLimit = 1
178
166
  // The file as it is (HEIC stays HEIC): it is decoded once, below, at the size wanted.
179
167
  configuration.preferredAssetRepresentationMode = .current
180
- let delegate = LibraryDelegate(maxPixels: maxPixels, quality: quality, done: finish)
168
+ let delegate = LibraryDelegate(maxPixels: maxPixels, quality: quality, requestToken: requestToken, isCancelled: isCancelled, done: done)
181
169
  let picker = PHPickerViewController(configuration: configuration)
182
170
  picker.delegate = delegate
171
+ delegate.controller = picker
183
172
  activePicker = delegate
184
173
  if !CraftPresenter.present(picker, from: nil) {
185
- activePicker = nil
186
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
174
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
187
175
  }
188
176
  }
189
177
 
@@ -228,56 +216,174 @@ enum CraftNativeFiles {
228
216
  return "\(base).jpg"
229
217
  }
230
218
 
231
- private final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
219
+ final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
232
220
  let maxPixels: Int
233
221
  let quality: Double
222
+ let videoLimit: Int?
223
+ let requestToken: String?
224
+ let isCancelled: () -> Bool
234
225
  let done: (Result<Any, CraftNativeActionError>) -> Void
235
-
236
- init(maxPixels: Int, quality: Double, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
226
+ weak var controller: PHPickerViewController?
227
+ private let stateLock = NSLock()
228
+ private var cancelled = false
229
+ private var answered = false
230
+ private var finished = false
231
+ private var progress: Progress?
232
+
233
+ init(maxPixels: Int = defaultMaxPixels, quality: Double = defaultQuality, videoLimit: Int? = nil, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
237
234
  self.maxPixels = maxPixels
238
235
  self.quality = quality
236
+ self.videoLimit = videoLimit
237
+ self.requestToken = requestToken
238
+ self.isCancelled = isCancelled
239
239
  self.done = done
240
240
  }
241
241
 
242
+ private func cancellationRequested() -> Bool {
243
+ stateLock.lock()
244
+ let value = cancelled
245
+ stateLock.unlock()
246
+ return value || isCancelled()
247
+ }
248
+
249
+ func cancel() {
250
+ guard !finished else { return }
251
+ stateLock.lock()
252
+ cancelled = true
253
+ stateLock.unlock()
254
+ answered = true
255
+ progress?.cancel()
256
+ controller?.dismiss(animated: false)
257
+ // A provider may ignore Progress.cancel or never answer. Release
258
+ // the UI now, retaining our own cancellation flag after the bridge
259
+ // drains its token so a late callback cannot resurrect the import.
260
+ finish(.failure(importCancelled()))
261
+ }
262
+
263
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
264
+ DispatchQueue.main.async {
265
+ let cancelled = self.cancellationRequested()
266
+ if self.finished || cancelled {
267
+ if case .success(let value) = result,
268
+ let file = value as? [String: Any], let path = file["path"] as? String {
269
+ CraftNativeFiles.remove(path)
270
+ }
271
+ }
272
+ guard !self.finished else { return }
273
+ self.finished = true
274
+ self.progress = nil
275
+ self.controller?.dismiss(animated: false)
276
+ if activePicker === self { activePicker = nil }
277
+ self.done(cancelled ? .failure(importCancelled()) : result)
278
+ }
279
+ }
280
+
242
281
  func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
282
+ guard !answered else { return }
243
283
  picker.dismiss(animated: true)
244
284
  guard let provider = results.first?.itemProvider else {
245
- done(.success(NSNull()))
285
+ answered = true
286
+ finish(.success(NSNull()))
246
287
  return
247
288
  }
248
- guard provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
249
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "That is not a photo")))
289
+ load(provider)
290
+ }
291
+
292
+ func load(_ provider: NSItemProvider) {
293
+ guard !answered else { return }
294
+ answered = true
295
+ guard !cancellationRequested() else { finish(.failure(importCancelled())); return }
296
+ let type = videoLimit == nil ? UTType.image.identifier : UTType.movie.identifier
297
+ guard provider.hasItemConformingToTypeIdentifier(type) else {
298
+ finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The selected media has an unsupported type")))
250
299
  return
251
300
  }
252
301
  let name = CraftNativeFiles.jpegName(provider.suggestedName)
253
- let (maxPixels, quality, done) = (self.maxPixels, self.quality, self.done)
254
302
  // The file is only there for the length of this callback.
255
- provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
303
+ progress = provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in
304
+ guard !self.cancellationRequested() else { self.finish(.failure(importCancelled())); return }
305
+ if let limit = self.videoLimit {
306
+ guard let url else {
307
+ self.finish(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
308
+ return
309
+ }
310
+ self.finish(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: self.cancellationRequested))
311
+ return
312
+ }
256
313
  guard let url, let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
257
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: error?.localizedDescription ?? "The photo could not be read")))
314
+ self.finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: error?.localizedDescription ?? "The photo could not be read")))
258
315
  return
259
316
  }
260
- done(CraftNativeFiles.writeJPEG(from: source, maxPixels: maxPixels, quality: quality, name: name))
317
+ self.finish(CraftNativeFiles.writeJPEG(from: source, maxPixels: self.maxPixels, quality: self.quality, name: name))
261
318
  }
262
319
  }
263
320
  }
264
321
 
265
322
  #if CRAFT_PERMISSION_CAMERA
266
- private final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
323
+ /// The caller checks camera permission configuration and device availability
324
+ /// before constructing a camera picker. Keep presentation ownership here so
325
+ /// failure and cancellation use the same exactly-once settlement path.
326
+ static func presentCamera(_ picker: UIImagePickerController, maxPixels: Int, quality: Double, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
327
+ let delegate = CameraDelegate(maxPixels: maxPixels, quality: quality, requestToken: requestToken, isCancelled: isCancelled, done: done)
328
+ picker.delegate = delegate
329
+ delegate.controller = picker
330
+ activePicker = delegate
331
+ if !CraftPresenter.present(picker, from: nil) {
332
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
333
+ }
334
+ }
335
+
336
+ final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
267
337
  let maxPixels: Int
268
338
  let quality: Double
339
+ let requestToken: String?
340
+ let isCancelled: () -> Bool
269
341
  let done: (Result<Any, CraftNativeActionError>) -> Void
342
+ weak var controller: UIImagePickerController?
343
+ private var answered = false
344
+ private var finished = false
345
+ private var cancelled = false
270
346
 
271
- init(maxPixels: Int, quality: Double, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
347
+ init(maxPixels: Int, quality: Double, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
272
348
  self.maxPixels = maxPixels
273
349
  self.quality = quality
350
+ self.requestToken = requestToken
351
+ self.isCancelled = isCancelled
274
352
  self.done = done
275
353
  }
276
354
 
355
+ func cancel() {
356
+ guard !finished else { return }
357
+ cancelled = true
358
+ answered = true
359
+ controller?.dismiss(animated: false)
360
+ finish(.failure(importCancelled()))
361
+ }
362
+
363
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
364
+ DispatchQueue.main.async {
365
+ let cancelled = self.cancelled || self.isCancelled()
366
+ if self.finished || cancelled {
367
+ if case .success(let value) = result,
368
+ let file = value as? [String: Any], let path = file["path"] as? String {
369
+ CraftNativeFiles.remove(path)
370
+ }
371
+ }
372
+ guard !self.finished else { return }
373
+ self.finished = true
374
+ self.controller?.dismiss(animated: false)
375
+ if activePicker === self { activePicker = nil }
376
+ self.done(cancelled ? .failure(importCancelled()) : result)
377
+ }
378
+ }
379
+
277
380
  func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
381
+ guard !answered else { return }
382
+ answered = true
383
+ guard !cancelled, !isCancelled() else { finish(.failure(importCancelled())); return }
278
384
  picker.dismiss(animated: true)
279
385
  guard let image = info[.originalImage] as? UIImage else {
280
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
386
+ finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
281
387
  return
282
388
  }
283
389
  // Drawn at the size wanted, which also turns it upright.
@@ -289,17 +395,18 @@ enum CraftNativeFiles {
289
395
  format.opaque = true
290
396
  let drawn = UIGraphicsImageRenderer(size: size, format: format).image { _ in image.draw(in: CGRect(origin: .zero, size: size)) }
291
397
  guard let cgImage = drawn.cgImage else {
292
- done(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
398
+ finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: "The photo could not be read")))
293
399
  return
294
400
  }
295
401
  let formatter = DateFormatter()
296
402
  formatter.dateFormat = "yyyyMMdd_HHmmss"
297
- done(CraftNativeFiles.writeJPEG(cgImage, quality: quality, name: "IMG_\(formatter.string(from: Date())).jpg"))
403
+ finish(CraftNativeFiles.writeJPEG(cgImage, quality: quality, name: "IMG_\(formatter.string(from: Date())).jpg"))
298
404
  }
299
405
 
300
406
  func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
301
- picker.dismiss(animated: true)
302
- done(.success(NSNull()))
407
+ guard !answered else { return }
408
+ answered = true
409
+ finish(.success(NSNull()))
303
410
  }
304
411
  }
305
412
 
@@ -309,7 +416,7 @@ enum CraftNativeFiles {
309
416
 
310
417
  /// System pickers grant access to just the selected video. Copy it while
311
418
  /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
312
- static func pickVideo(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
419
+ static func pickVideo(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
313
420
  guard !isCancelled() else { done(.failure(importCancelled())); return }
314
421
  guard activePicker == nil else {
315
422
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
@@ -321,33 +428,30 @@ enum CraftNativeFiles {
321
428
  guard value.isFinite else { return nil }
322
429
  return Int(max(1, min(Double(512 * 1024 * 1024), value)))
323
430
  } ?? 512 * 1024 * 1024
324
- let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
325
- DispatchQueue.main.async { activePicker = nil; done(result) }
326
- }
327
431
  if options["source"] as? String == "files" {
328
- let delegate = VideoDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
432
+ let delegate = DocumentDelegate(limit: limit, video: true, requestToken: requestToken, isCancelled: isCancelled, done: done)
329
433
  var types: [UTType] = [.movie]
330
434
  if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
331
435
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
332
436
  picker.allowsMultipleSelection = false
333
437
  picker.delegate = delegate
438
+ delegate.controller = picker
334
439
  activePicker = delegate
335
440
  if !CraftPresenter.present(picker, from: nil) {
336
- activePicker = nil
337
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
441
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
338
442
  }
339
443
  } else {
340
444
  var configuration = PHPickerConfiguration()
341
445
  configuration.filter = .videos
342
446
  configuration.selectionLimit = 1
343
447
  configuration.preferredAssetRepresentationMode = .current
344
- let delegate = VideoLibraryDelegate(limit: limit, isCancelled: isCancelled, done: finish)
448
+ let delegate = LibraryDelegate(videoLimit: limit, requestToken: requestToken, isCancelled: isCancelled, done: done)
345
449
  let picker = PHPickerViewController(configuration: configuration)
346
450
  picker.delegate = delegate
451
+ delegate.controller = picker
347
452
  activePicker = delegate
348
453
  if !CraftPresenter.present(picker, from: nil) {
349
- activePicker = nil
350
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
454
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
351
455
  }
352
456
  }
353
457
  }
@@ -357,7 +461,7 @@ enum CraftNativeFiles {
357
461
  /// Pick one or more documents and copy them into app-owned storage while
358
462
  /// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
359
463
  /// UTIs, or filename extensions; an empty list means any document.
360
- static func pickFile(_ options: [String: Any], isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
464
+ static func pickFile(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
361
465
  guard !isCancelled() else { done(.failure(importCancelled())); return }
362
466
  guard activePicker == nil else {
363
467
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
@@ -382,17 +486,14 @@ enum CraftNativeFiles {
382
486
  done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes are invalid")))
383
487
  return
384
488
  }
385
- let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
386
- DispatchQueue.main.async { activePicker = nil; done(result) }
387
- }
388
- let delegate = FileDocumentDelegate(limit: limit, isCancelled: isCancelled, done: finish)
489
+ let delegate = DocumentDelegate(limit: limit, video: false, requestToken: requestToken, isCancelled: isCancelled, done: done)
389
490
  let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
390
491
  picker.allowsMultipleSelection = selectionLimit > 1
391
492
  picker.delegate = delegate
493
+ delegate.controller = picker
392
494
  activePicker = delegate
393
495
  if !CraftPresenter.present(picker, from: nil) {
394
- activePicker = nil
395
- done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
496
+ delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
396
497
  }
397
498
  }
398
499
 
@@ -531,17 +632,85 @@ enum CraftNativeFiles {
531
632
  }
532
633
  }
533
634
 
534
- private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
635
+ /// Screen disposal can cancel a request while its system picker is open.
636
+ /// A stale request must never dismiss a newer request's picker.
637
+ static func cancelPicker(requestToken: String) {
638
+ DispatchQueue.main.async {
639
+ #if CRAFT_PERMISSION_CAMERA
640
+ if let picker = activePicker as? CameraDelegate, picker.requestToken == requestToken {
641
+ picker.cancel()
642
+ return
643
+ }
644
+ #endif
645
+ if let picker = activePicker as? LibraryDelegate, picker.requestToken == requestToken {
646
+ picker.cancel()
647
+ return
648
+ }
649
+ guard let picker = activePicker as? DocumentDelegate,
650
+ picker.requestToken == requestToken else { return }
651
+ picker.cancel()
652
+ }
653
+ }
654
+
655
+ final class DocumentDelegate: NSObject, UIDocumentPickerDelegate {
535
656
  let limit: Int
657
+ let video: Bool
658
+ let requestToken: String?
536
659
  let isCancelled: () -> Bool
537
660
  let done: (Result<Any, CraftNativeActionError>) -> Void
538
- init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
539
- func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
661
+ weak var controller: UIDocumentPickerViewController?
662
+ private var answered = false
663
+ private var finished = false
664
+ private var importing = false
665
+
666
+ init(limit: Int, video: Bool, requestToken: String?, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
667
+ self.limit = limit; self.video = video; self.requestToken = requestToken
668
+ self.isCancelled = isCancelled; self.done = done
669
+ }
670
+
671
+ func cancel() {
672
+ guard !finished else { return }
673
+ answered = true
674
+ controller?.dismiss(animated: false)
675
+ // Do not drain the cancellation token while a worker still polls
676
+ // it: that would let the remainder of a cancelled copy succeed.
677
+ if !importing { finish(.failure(importCancelled())) }
678
+ }
679
+
680
+ func finish(_ result: Result<Any, CraftNativeActionError>) {
681
+ DispatchQueue.main.async {
682
+ guard !self.finished else { return }
683
+ self.finished = true
684
+ self.controller?.dismiss(animated: false)
685
+ if activePicker === self { activePicker = nil }
686
+ self.done(result)
687
+ }
688
+ }
689
+
690
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
691
+ guard !answered else { return }
692
+ answered = true
693
+ finish(.success(video ? NSNull() : []))
694
+ }
695
+
540
696
  func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
541
- guard !urls.isEmpty else { done(.success([])); return }
542
- let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
697
+ guard !answered else { return }
698
+ answered = true
699
+ guard !isCancelled() else { finish(.failure(importCancelled())); return }
700
+ guard !urls.isEmpty else { finish(.success(video ? NSNull() : [])); return }
701
+ importing = true
702
+ let (limit, isCancelled, video) = (self.limit, self.isCancelled, self.video)
543
703
  DispatchQueue.global(qos: .userInitiated).async {
544
- done(CraftNativeFiles.copyFiles(urls, limit: limit, isCancelled: isCancelled))
704
+ let result: Result<Any, CraftNativeActionError>
705
+ if video {
706
+ let url = urls[0]
707
+ let granted = url.startAccessingSecurityScopedResource()
708
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
709
+ result = CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled)
710
+ } else {
711
+ result = CraftNativeFiles.copyFiles(urls, limit: limit, isCancelled: isCancelled)
712
+ }
713
+ self.finish(result)
545
714
  }
546
715
  }
547
716
  }
@@ -555,42 +724,6 @@ enum CraftNativeFiles {
555
724
  return copyImport(source, limit: limit, type: type, isCancelled: isCancelled)
556
725
  }
557
726
 
558
- private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
559
- let limit: Int
560
- let isCancelled: () -> Bool
561
- let done: (Result<Any, CraftNativeActionError>) -> Void
562
- init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
563
- func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
564
- picker.dismiss(animated: true)
565
- guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
566
- let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
567
- provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
568
- guard let url else {
569
- done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
570
- return
571
- }
572
- done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
573
- }
574
- }
575
- }
576
-
577
- private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
578
- let limit: Int
579
- let isCancelled: () -> Bool
580
- let done: (Result<Any, CraftNativeActionError>) -> Void
581
- init(limit: Int, isCancelled: @escaping () -> Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.isCancelled = isCancelled; self.done = done }
582
- func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
583
- func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
584
- guard let url = urls.first else { done(.success(NSNull())); return }
585
- let (limit, isCancelled, done) = (self.limit, self.isCancelled, self.done)
586
- DispatchQueue.global(qos: .userInitiated).async {
587
- let granted = url.startAccessingSecurityScopedResource()
588
- defer { if granted { url.stopAccessingSecurityScopedResource() } }
589
- done(CraftNativeFiles.copyVideo(url, limit: limit, isCancelled: isCancelled))
590
- }
591
- }
592
- }
593
-
594
727
  // MARK: Sending files
595
728
 
596
729
  /// A multipart/form-data body from a screen's `FormData`: parts are