craft-native 0.0.187 → 0.0.191

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
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.187";
3427
+ var version = "0.0.191";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.187";
312
+ var version = "0.0.191";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.187";
60
+ var version = "0.0.191";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -614,7 +614,10 @@ final class CraftChrome: ObservableObject {
614
614
  let id: String
615
615
  let title: String
616
616
  let symbol: String
617
- let badge: String?
617
+ var badge: String?
618
+ var iconText: String? = nil
619
+ var accessibilityLabel: String? = nil
620
+ var image: String? = nil
618
621
  }
619
622
 
620
623
  @Published var tabs: [Tab] = []
@@ -668,6 +671,23 @@ final class CraftChrome: ObservableObject {
668
671
  /// the bar away while it shows (`tabBarHidden`, CraftNativeScreen.swift);
669
672
  /// the next screen shown says again. The page's own hideTabBar is apart.
670
673
  private(set) var hiddenByScreen = false
674
+
675
+ /// Change one tab's icon without replacing tabs, navigation stacks or selection.
676
+ func updateTab(_ item: [String: Any]) {
677
+ guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
678
+ if item.keys.contains("iconText") {
679
+ tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
680
+ }
681
+ if item.keys.contains("accessibilityLabel") {
682
+ tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
683
+ }
684
+ if item.keys.contains("image") {
685
+ tabs[index].image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
686
+ }
687
+ if item.keys.contains("badge") {
688
+ tabs[index].badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
689
+ }
690
+ }
671
691
  func setHiddenByScreen(_ hidden: Bool) {
672
692
  guard hiddenByScreen != hidden else { return }
673
693
  hiddenByScreen = hidden
@@ -941,9 +961,28 @@ struct CraftTabBarView: View {
941
961
  Button { chrome.tap(tab) } label: {
942
962
  VStack(spacing: 3) {
943
963
  ZStack(alignment: .topTrailing) {
944
- Image(systemName: symbol(tab.symbol, selected: isSelected))
945
- .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
964
+ if let image = tab.image, let url = URL(string: image) {
965
+ AsyncImage(url: url) { image in
966
+ image.resizable().scaledToFill()
967
+ } placeholder: {
968
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
969
+ }
970
+ .frame(width: iconSize + 4, height: iconSize + 4)
971
+ .clipShape(Circle())
946
972
  .frame(height: iconSize + 6)
973
+ } else if let text = tab.iconText {
974
+ Text(text)
975
+ .font(.system(size: iconSize, weight: .semibold, design: .rounded))
976
+ .monospacedDigit()
977
+ .foregroundStyle(Color.red)
978
+ .lineLimit(1)
979
+ .minimumScaleFactor(0.7)
980
+ .frame(height: iconSize + 6)
981
+ } else {
982
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
983
+ .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
984
+ .frame(height: iconSize + 6)
985
+ }
947
986
  if let badge = tab.badge {
948
987
  Text(badge)
949
988
  .font(.system(size: badgeSize, weight: .bold))
@@ -975,7 +1014,7 @@ struct CraftTabBarView: View {
975
1014
  Image(systemName: symbol(tab.symbol, selected: isSelected))
976
1015
  Text(tab.title)
977
1016
  }
978
- .accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
1017
+ .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
979
1018
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
980
1019
  }
981
1020
  }
@@ -992,7 +992,10 @@ final class CraftHybridWebSlot: UIViewController {
992
992
 
993
993
  override func viewWillAppear(_ animated: Bool) {
994
994
  super.viewWillAppear(animated)
995
- (parent?.parent as? CraftHybridController)?.adoptWeb(into: self)
995
+ if let controller = parent?.parent as? CraftHybridController {
996
+ controller.prepareWebTransition(to: self)
997
+ controller.adoptWeb(into: self)
998
+ }
996
999
  }
997
1000
  }
998
1001
 
@@ -1011,11 +1014,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1011
1014
  private var shownStacks: [ObjectIdentifier: [UIViewController]] = [:]
1012
1015
  /// A tab whose page has not caught up yet; shown when it has, or soon.
1013
1016
  private var pendingTab: (id: String, deadline: DispatchWorkItem)?
1017
+ /// The live WebView moves to the destination before UIKit animates a web
1018
+ /// push. Keep the outgoing page visible until that transition completes.
1019
+ private var webTransitionSnapshots: [UIView] = []
1014
1020
  private var openingWeb = false
1015
1021
  private var firstFrameLogged = false
1016
1022
  private var tabBarHeight: CGFloat = 0
1017
1023
  private var tabSwipeRecognizers: [UIGestureRecognizer] = []
1018
1024
  private var activeTabSwipeTarget: String?
1025
+ /// The edge the incoming tab started from for the current gesture. Keep
1026
+ /// this separate from the live frame: deriving it after the drag begins
1027
+ /// makes the target jump when the translation crosses the midpoint.
1028
+ private var tabSwipeFrom: CGFloat = 0
1019
1029
 
1020
1030
  init(config: CraftConfig) {
1021
1031
  self.config = config
@@ -1134,7 +1144,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1134
1144
  }
1135
1145
  target.view.isHidden = false
1136
1146
  let width = view.bounds.width
1137
- target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)
1147
+ tabSwipeFrom = -direction * width
1148
+ target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
1138
1149
  view.bringSubviewToFront(target.view)
1139
1150
  CraftChrome.shared.beginTabSwipe(to: id)
1140
1151
  return true
@@ -1144,9 +1155,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1144
1155
  guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1145
1156
  let target = stacks[targetId] else { return }
1146
1157
  let width = max(1, view.bounds.width)
1147
- let start = target.view.frame.minX >= width * 0.5 ? width : -width
1148
1158
  let offset = min(width, max(-width, translation))
1149
- target.view.frame.origin.x = start + offset
1159
+ target.view.frame.origin.x = tabSwipeFrom + offset
1150
1160
  current.view.frame.origin.x = offset
1151
1161
  }
1152
1162
 
@@ -1244,6 +1254,26 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1244
1254
  ])
1245
1255
  }
1246
1256
 
1257
+ /// UIKit calls the destination's viewWillAppear before it starts a push.
1258
+ /// Since the hybrid shell has one WebView shared by all web entries, moving
1259
+ /// it at that point would leave the outgoing slot blank for the whole
1260
+ /// transition. A snapshot gives that slot the page it was showing while
1261
+ /// the live WebView is adopted by the destination.
1262
+ func prepareWebTransition(to slot: CraftHybridWebSlot) {
1263
+ guard let nav = slot.navigationController,
1264
+ nav === visibleStack,
1265
+ let from = nav.transitionCoordinator?.viewController(forKey: .from),
1266
+ let source = from as? CraftHybridWebSlot,
1267
+ source !== slot,
1268
+ let webHost,
1269
+ webHost.view.superview === source.view,
1270
+ let snapshot = webHost.view.snapshotView(afterScreenUpdates: false) else { return }
1271
+ snapshot.frame = source.view.bounds
1272
+ snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1273
+ source.view.addSubview(snapshot)
1274
+ webTransitionSnapshots.append(snapshot)
1275
+ }
1276
+
1247
1277
  private var visibleStack: UINavigationController? { stacks[currentTab] }
1248
1278
 
1249
1279
  var webIsVisible: Bool {
@@ -1593,6 +1623,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1593
1623
  if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
1594
1624
  hybrid.pageBack(to: depth - 1)
1595
1625
  }
1626
+ for snapshot in webTransitionSnapshots { snapshot.removeFromSuperview() }
1627
+ webTransitionSnapshots.removeAll()
1596
1628
  // Back at a tab's native root while the page, with no history left
1597
1629
  // to go back through (it reloaded on the screen just popped), still
1598
1630
  // shows that screen: it moves to the root's path. Left there, the
@@ -31,7 +31,7 @@ enum CraftNativeActions {
31
31
  private static let fetchSession: URLSession = {
32
32
  let configuration = URLSessionConfiguration.default
33
33
  configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
34
- configuration.timeoutIntervalForResource = craftNativeFetchTimeoutSeconds
34
+ configuration.timeoutIntervalForResource = 600
35
35
  return URLSession(configuration: configuration)
36
36
  }()
37
37
  private static var initialDeepLinkClaimed = false
@@ -219,6 +219,12 @@ enum CraftNativeActions {
219
219
  CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { complete(requestToken, $0, completion) }
220
220
  }
221
221
  return
222
+ case ("Files", "pickVideo"):
223
+ let options = args.first as? [String: Any] ?? [:]
224
+ DispatchQueue.main.async {
225
+ CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
226
+ }
227
+ return
222
228
  case ("Files", "download"):
223
229
  download(requestToken: requestToken, args: args, completion: completion)
224
230
  return
@@ -594,6 +600,7 @@ enum CraftNativeActions {
594
600
  request.setValue(value, forHTTPHeaderField: name)
595
601
  }
596
602
  }
603
+ var uploadFile: URL?
597
604
  if let form = options["form"] as? [Any] {
598
605
  // A screen's FormData: text fields, and files craft.files made.
599
606
  guard method != "GET", method != "HEAD" else {
@@ -604,8 +611,9 @@ enum CraftNativeActions {
604
611
  case .failure(let error):
605
612
  complete(requestToken, .failure(error), completion)
606
613
  return
607
- case .success(let (data, boundary)):
608
- request.httpBody = data
614
+ case .success(let (file, boundary)):
615
+ uploadFile = file
616
+ request.timeoutInterval = 600
609
617
  request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
610
618
  }
611
619
  } else if let body = options["body"] as? String {
@@ -619,7 +627,8 @@ enum CraftNativeActions {
619
627
  return
620
628
  }
621
629
 
622
- let task = fetchSession.dataTask(with: request) { data, response, error in
630
+ let responseHandler: (Data?, URLResponse?, Error?) -> Void = { data, response, error in
631
+ if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
623
632
  cancellationLock.lock()
624
633
  fetchTasks.removeValue(forKey: requestToken)
625
634
  cancellationLock.unlock()
@@ -656,11 +665,14 @@ enum CraftNativeActions {
656
665
  "body": String(decoding: body, as: UTF8.self),
657
666
  ]), completion)
658
667
  }
668
+ let task: URLSessionTask = uploadFile.map { fetchSession.uploadTask(with: request, fromFile: $0, completionHandler: responseHandler) }
669
+ ?? fetchSession.dataTask(with: request, completionHandler: responseHandler)
659
670
  cancellationLock.lock()
660
671
  let alreadyCancelled = cancelledRequests.contains(requestToken)
661
672
  if !alreadyCancelled { fetchTasks[requestToken] = task }
662
673
  cancellationLock.unlock()
663
674
  if !alreadyCancelled { task.resume() }
675
+ else if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
664
676
  }
665
677
 
666
678
  /// `craft.files.download`: a GET straight to a file made here (for the
@@ -274,41 +274,147 @@ enum CraftNativeFiles {
274
274
  }
275
275
  }
276
276
 
277
+ // MARK: Picking a video
278
+
279
+ /// System pickers grant access to just the selected video. Copy it while
280
+ /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
281
+ static func pickVideo(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
282
+ guard activePicker == nil else {
283
+ done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
284
+ return
285
+ }
286
+ let limit = min(512 * 1024 * 1024, max(1, (options["maxBytes"] as? NSNumber)?.intValue ?? 512 * 1024 * 1024))
287
+ let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
288
+ DispatchQueue.main.async { activePicker = nil; done(result) }
289
+ }
290
+ if options["source"] as? String == "files" {
291
+ let delegate = VideoDocumentDelegate(limit: limit, done: finish)
292
+ var types: [UTType] = [.movie]
293
+ if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
294
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
295
+ picker.allowsMultipleSelection = false
296
+ picker.delegate = delegate
297
+ activePicker = delegate
298
+ if !CraftPresenter.present(picker, from: nil) {
299
+ activePicker = nil
300
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
301
+ }
302
+ } else {
303
+ var configuration = PHPickerConfiguration()
304
+ configuration.filter = .videos
305
+ configuration.selectionLimit = 1
306
+ configuration.preferredAssetRepresentationMode = .current
307
+ let delegate = VideoLibraryDelegate(limit: limit, done: finish)
308
+ let picker = PHPickerViewController(configuration: configuration)
309
+ picker.delegate = delegate
310
+ activePicker = delegate
311
+ if !CraftPresenter.present(picker, from: nil) {
312
+ activePicker = nil
313
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
314
+ }
315
+ }
316
+ }
317
+
318
+ static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
319
+ let ext = source.pathExtension.lowercased()
320
+ let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
321
+ guard let type = types[ext] else {
322
+ return .failure(CraftNativeActionError(code: "INVALID_VIDEO", message: "Choose an MP4, MOV or WebM video."))
323
+ }
324
+ do {
325
+ let size = ((try FileManager.default.attributesOfItem(atPath: source.path))[.size] as? NSNumber)?.intValue ?? 0
326
+ guard size > 0, size <= limit else {
327
+ return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty video under \(limit / 1024 / 1024)MB."))
328
+ }
329
+ let destination = try newFile(named: source.lastPathComponent)
330
+ do { try FileManager.default.copyItem(at: source, to: destination) }
331
+ catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
332
+ return .success(describe(destination, type: type))
333
+ } catch {
334
+ return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
335
+ }
336
+ }
337
+
338
+ private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
339
+ let limit: Int
340
+ let done: (Result<Any, CraftNativeActionError>) -> Void
341
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
342
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
343
+ picker.dismiss(animated: true)
344
+ guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
345
+ let (limit, done) = (self.limit, self.done)
346
+ provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
347
+ guard let url else {
348
+ done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
349
+ return
350
+ }
351
+ done(CraftNativeFiles.copyVideo(url, limit: limit))
352
+ }
353
+ }
354
+ }
355
+
356
+ private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
357
+ let limit: Int
358
+ let done: (Result<Any, CraftNativeActionError>) -> Void
359
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
360
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
361
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
362
+ guard let url = urls.first else { done(.success(NSNull())); return }
363
+ let (limit, done) = (self.limit, self.done)
364
+ DispatchQueue.global(qos: .userInitiated).async {
365
+ let granted = url.startAccessingSecurityScopedResource()
366
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
367
+ done(CraftNativeFiles.copyVideo(url, limit: limit))
368
+ }
369
+ }
370
+ }
371
+
277
372
  // MARK: Sending files
278
373
 
279
374
  /// A multipart/form-data body from a screen's `FormData`: parts are
280
375
  /// `{ name, value }` or `{ name, path, filename?, type? }`, a file made
281
376
  /// here. Answers the body and its boundary.
282
- static func multipart(_ parts: [Any]) -> Result<(Data, String), CraftNativeActionError> {
377
+ static func multipart(_ parts: [Any]) -> Result<(URL, String), CraftNativeActionError> {
283
378
  let boundary = "craft-\(UUID().uuidString)"
284
- var body = Data()
285
- func append(_ text: String) { body.append(Data(text.utf8)) }
286
- for item in parts {
287
- guard let part = item as? [String: Any], let name = part["name"] as? String else {
288
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name"))
289
- }
290
- append("--\(boundary)\r\n")
291
- if let path = part["path"] as? String {
292
- guard let url = owned(path) else {
293
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there"))
379
+ var temporary: URL?
380
+ do {
381
+ let destination = try newFile(named: "upload.multipart")
382
+ temporary = destination
383
+ FileManager.default.createFile(atPath: destination.path, contents: nil)
384
+ let output = try FileHandle(forWritingTo: destination)
385
+ defer { try? output.close() }
386
+ func append(_ text: String) throws { try output.write(contentsOf: Data(text.utf8)) }
387
+ for item in parts {
388
+ guard let part = item as? [String: Any], let name = part["name"] as? String else {
389
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name")
294
390
  }
295
- guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else {
296
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file could not be read"))
391
+ try append("--\(boundary)\r\n")
392
+ if let path = part["path"] as? String {
393
+ guard let url = owned(path) else {
394
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there")
395
+ }
396
+ let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
397
+ let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
398
+ try append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
399
+ try append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
400
+ let input = try FileHandle(forReadingFrom: url)
401
+ defer { try? input.close() }
402
+ while let chunk = try input.read(upToCount: 64 * 1024), !chunk.isEmpty {
403
+ try output.write(contentsOf: chunk)
404
+ }
405
+ try append("\r\n")
406
+ } else {
407
+ try append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
408
+ try append(part["value"] as? String ?? "")
409
+ try append("\r\n")
297
410
  }
298
- let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
299
- let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
300
- append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
301
- append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
302
- body.append(data)
303
- append("\r\n")
304
- } else {
305
- append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
306
- append(part["value"] as? String ?? "")
307
- append("\r\n")
308
411
  }
412
+ try append("--\(boundary)--\r\n")
413
+ return .success((destination, boundary))
414
+ } catch {
415
+ if let temporary { remove(temporary.path) }
416
+ return .failure((error as? CraftNativeActionError) ?? CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
309
417
  }
310
- append("--\(boundary)--\r\n")
311
- return .success((body, boundary))
312
418
  }
313
419
 
314
420
  /// A name inside a quoted header value, escaped as browsers send it.