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/README.md +41 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +107 -39
- package/dist/android/templates/MainActivityNative.kt.template +2 -0
- package/dist/browser/checkpoints.d.ts +6 -0
- package/dist/browser.js +186 -26
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftHybrid.swift +16 -3
- package/dist/ios/templates/CraftNativeActions.swift +4 -3
- package/dist/ios/templates/CraftNativeFiles.swift +227 -94
- package/dist/ios/templates/CraftNativeScreen.swift +39 -12
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
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.
|
|
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
package/dist/index.js
CHANGED
|
@@ -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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
+
answered = true
|
|
286
|
+
finish(.success(NSNull()))
|
|
246
287
|
return
|
|
247
288
|
}
|
|
248
|
-
|
|
249
|
-
|
|
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:
|
|
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
|
-
|
|
314
|
+
self.finish(.failure(CraftNativeActionError(code: "DECODE_ERROR", message: error?.localizedDescription ?? "The photo could not be read")))
|
|
258
315
|
return
|
|
259
316
|
}
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
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 !
|
|
542
|
-
|
|
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
|
-
|
|
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
|