craft-native 0.0.385 → 0.0.390
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/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.390";
|
|
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
|
@@ -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
|
|
@@ -7367,6 +7367,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7367
7367
|
}
|
|
7368
7368
|
if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
|
|
7369
7369
|
applyNavigationBarAppearance()
|
|
7370
|
+
trackContentScrollView()
|
|
7370
7371
|
}
|
|
7371
7372
|
// Asked for after the bar was hidden for a bare root screen.
|
|
7372
7373
|
if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
|
|
@@ -7589,7 +7590,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7589
7590
|
/// title collapses as it scrolls, and the bar's background follows it.
|
|
7590
7591
|
private func trackContentScrollView() {
|
|
7591
7592
|
guard let renderedRoot else { return }
|
|
7593
|
+
// UIKit can resize the root before nested flex wrappers lay out.
|
|
7594
|
+
// Resolve their frames before checking empty siblings or edges so
|
|
7595
|
+
// old child frames cannot toggle the root constraints each pass.
|
|
7596
|
+
rootStack.layoutIfNeeded()
|
|
7592
7597
|
func find(_ node: RenderedNode) -> UIScrollView? {
|
|
7598
|
+
// Modal content belongs to a separate presentation. Hidden
|
|
7599
|
+
// branches must not steal tracking from the visible screen.
|
|
7600
|
+
guard node.type != "Modal", !node.view.isHidden else { return nil }
|
|
7593
7601
|
if let scroll = node.view as? CraftNativeScrollView, scroll.isVertical, scroll.isScrollEnabled { return scroll }
|
|
7594
7602
|
if let list = node.view as? CraftNativeFlatList, list.isScrollEnabled,
|
|
7595
7603
|
(list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal { return list }
|
|
@@ -7606,12 +7614,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7606
7614
|
return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? node : nil
|
|
7607
7615
|
}
|
|
7608
7616
|
guard paddingInsets(node.style).top == 0,
|
|
7609
|
-
let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden }) else { return nil }
|
|
7617
|
+
let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden && $0.view.bounds.height > 0 }) else { return nil }
|
|
7610
7618
|
return leading(first)
|
|
7611
7619
|
}
|
|
7612
7620
|
let scroll = find(renderedRoot)
|
|
7613
7621
|
let leadingScroll = leading(renderedRoot)
|
|
7614
|
-
//
|
|
7622
|
+
// The leading scroller may overflow beneath navigation chrome.
|
|
7615
7623
|
// A bounded list inside a form must clip its rows to its viewport.
|
|
7616
7624
|
if let leadingScroll, leadingScroll.style["overflow"] as? String != "hidden" {
|
|
7617
7625
|
leadingScroll.view.clipsToBounds = false
|
|
@@ -7626,13 +7634,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7626
7634
|
let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leadingScroll?.view)
|
|
7627
7635
|
let fullEdgeToEdge = rootIsSafeAreaView
|
|
7628
7636
|
let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
|
|
7629
|
-
//
|
|
7630
|
-
//
|
|
7631
|
-
//
|
|
7637
|
+
// Bottom clearance does not depend on the leading-scroll rule for
|
|
7638
|
+
// navigation titles. Follow the visible scroller through wrappers,
|
|
7639
|
+
// even after a header or empty sibling. Its laid-out viewport must
|
|
7640
|
+
// reach the root's bottom; zero-size/hidden siblings do not shorten
|
|
7641
|
+
// it. A bounded list, fixed footer, plain screen or presented sheet
|
|
7642
|
+
// must retain its safe-area bottom.
|
|
7632
7643
|
// Use UIKit's automatic inset for tab/accessory clearance, rather
|
|
7633
7644
|
// than adding a second explicit inset (also owned by keyboard code).
|
|
7634
7645
|
var tabbedScroll = false
|
|
7635
|
-
if let scroll, scroll
|
|
7646
|
+
if let scroll, scroll.bounds.height > 0,
|
|
7636
7647
|
let tabBar = tabBarController?.tabBar, !tabBar.isHidden, tabBar.alpha > 0,
|
|
7637
7648
|
let window = view.window, tabBar.window === window,
|
|
7638
7649
|
tabBar.convert(tabBar.bounds, to: view).intersects(view.bounds) {
|
|
@@ -7653,12 +7664,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7653
7664
|
rootLeadingToView?.isActive = fullEdgeToEdge
|
|
7654
7665
|
rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
|
|
7655
7666
|
rootTrailingToView?.isActive = fullEdgeToEdge
|
|
7656
|
-
scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7667
|
+
scroll?.contentInsetAdjustmentBehavior = (edgeToEdge || tabbedScroll) ? .automatic : .never
|
|
7668
|
+
if scroll !== trackedScrollView {
|
|
7669
|
+
#if compiler(>=6.2)
|
|
7670
|
+
if #available(iOS 26.0, *) {
|
|
7671
|
+
trackedScrollView?.topEdgeEffect.isHidden = false
|
|
7672
|
+
trackedScrollView?.bottomEdgeEffect.isHidden = false
|
|
7673
|
+
}
|
|
7674
|
+
#endif
|
|
7675
|
+
trackedScrollView = scroll
|
|
7676
|
+
// Keep UIKit's title collapse, tab selection and minimization
|
|
7677
|
+
// tracking; only the full-width edge backdrop changes below.
|
|
7678
|
+
setContentScrollView(scroll, for: [.top, .bottom])
|
|
7679
|
+
}
|
|
7680
|
+
#if compiler(>=6.2)
|
|
7681
|
+
if #available(iOS 26.0, *) {
|
|
7682
|
+
// Floating button groups carry their own material. UIKit's
|
|
7683
|
+
// separate scroll-edge wash would cover the content behind
|
|
7684
|
+
// and beyond them even with a transparent bar appearance.
|
|
7685
|
+
scroll?.topEdgeEffect.isHidden = topEdgeToEdge && !navigationBarSolid
|
|
7686
|
+
scroll?.bottomEdgeEffect.isHidden = tabbedScroll
|
|
7687
|
+
}
|
|
7688
|
+
#endif
|
|
7662
7689
|
}
|
|
7663
7690
|
|
|
7664
7691
|
private func observeNativeEvents() {
|
package/dist/types.d.ts
CHANGED
|
@@ -2397,5 +2397,9 @@ export interface MenubarState {
|
|
|
2397
2397
|
hiddenSystemItemCount?: number;
|
|
2398
2398
|
preserveSystemItems?: boolean;
|
|
2399
2399
|
markerWidth?: number;
|
|
2400
|
+
/** Read-only access to the macOS 27 menu bar arrangement. */
|
|
2401
|
+
layoutAuthorized?: boolean;
|
|
2402
|
+
/** Saved native control position, or -1 before an arrangement is available. */
|
|
2403
|
+
layoutPosition?: number;
|
|
2400
2404
|
error?: 'unavailable' | 'permissionRequired' | 'layoutUnavailable' | 'emptyGroup' | 'visibilityMismatch' | 'activationFailed' | 'activationTimeout' | null;
|
|
2401
2405
|
}
|