@craft-native/ios 0.0.491 → 0.0.496

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.491",
3
+ "version": "0.0.496",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -819,6 +819,8 @@ final class CraftChrome: ObservableObject {
819
819
  var image: String? = nil
820
820
  /// A compact floating icon, instead of the system full-width accessory shelf.
821
821
  var iconOnly = false
822
+ /// A shared material capsule; nil keeps separate floating buttons.
823
+ var accessoryLayout: String? = nil
822
824
  var companions: [Tab] = []
823
825
  /// A tap opens these destinations in a compact menu instead of navigating.
824
826
  var menu: [TabMenuItem] = []
@@ -964,10 +966,11 @@ final class CraftChrome: ObservableObject {
964
966
  if let item, let path = item["path"] as? String,
965
967
  path.hasPrefix("/"), !path.hasPrefix("//"),
966
968
  let title = item["title"] as? String {
967
- next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
969
+ next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "bubble.left",
968
970
  badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
969
971
  accessibilityLabel: item["accessibilityLabel"] as? String,
970
- iconOnly: item["iconOnly"] as? Bool ?? false,
972
+ iconOnly: (item["iconOnly"] as? Bool ?? false) || ["vertical", "horizontal"].contains(item["layout"] as? String ?? ""),
973
+ accessoryLayout: (item["layout"] as? String).flatMap { ["vertical", "horizontal"].contains($0) ? $0 : nil },
971
974
  companions: ((item["companions"] as? [[String: Any]]) ?? []).prefix(2).compactMap { action in
972
975
  guard let companionPath = action["path"] as? String,
973
976
  companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
@@ -1241,9 +1241,14 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1241
1241
  private var synchronizingTabs = false
1242
1242
  private var accessoryModel: CraftChrome.Tab?
1243
1243
  private let accessoryButton = UIButton(type: .system)
1244
+ // UIKit retains its shelf's content view across removal animations. Never
1245
+ // reparent that same button into floating chrome: the shelf can take it
1246
+ // back after our layout, leaving a perfectly sized but empty capsule.
1247
+ private let shelfAccessoryButton = UIButton(type: .system)
1244
1248
  private var companionButtons: [UIButton] = []
1245
1249
  private var companionModels: [CraftChrome.Tab] = []
1246
1250
  private var companionGlasses: [UIVisualEffectView] = []
1251
+ private var accessorySeparators: [UIView] = []
1247
1252
  private let accessoryBadge = UILabel()
1248
1253
  private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1249
1254
  private static let tabPictures = NSCache<NSURL, UIImage>()
@@ -1309,6 +1314,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1309
1314
  CraftChromeFonts.apply(to: tabBar)
1310
1315
  accessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1311
1316
  accessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
1317
+ shelfAccessoryButton.accessibilityIdentifier = "craft-tab-accessory"
1318
+ shelfAccessoryButton.addTarget(self, action: #selector(openTabAccessory), for: .touchUpInside)
1312
1319
  chromeSubscription = CraftChrome.shared.objectWillChange.sink { [weak self] in
1313
1320
  guard let self, !self.chromeUpdatePending else { return }
1314
1321
  self.chromeUpdatePending = true
@@ -1474,6 +1481,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1474
1481
  let button = UIButton(type: .system)
1475
1482
  var configuration = UIButton.Configuration.plain()
1476
1483
  configuration.image = UIImage(systemName: action.symbol)
1484
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1477
1485
  button.configuration = configuration
1478
1486
  button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1479
1487
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
@@ -1506,16 +1514,32 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1506
1514
  for (index, action) in companionModels.enumerated() {
1507
1515
  companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1508
1516
  }
1517
+ let grouped = model?.accessoryLayout != nil
1518
+ for glass in companionGlasses { glass.isHidden = grouped }
1519
+ for button in companionButtons { button.tintColor = grouped ? .label : nil }
1520
+ accessoryButton.tintColor = grouped ? .label : nil
1521
+ for separator in accessorySeparators { separator.removeFromSuperview() }
1522
+ accessorySeparators = grouped ? companionButtons.map { _ in
1523
+ let separator = UIView()
1524
+ separator.backgroundColor = .separator
1525
+ separator.isUserInteractionEnabled = false
1526
+ separator.isAccessibilityElement = false
1527
+ view.addSubview(separator)
1528
+ return separator
1529
+ } : []
1509
1530
  if let model {
1510
1531
  var configuration = UIButton.Configuration.plain()
1511
1532
  configuration.image = UIImage(systemName: model.symbol)
1533
+ configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1512
1534
  configuration.title = model.iconOnly ? nil : model.displayTitle
1513
1535
  configuration.subtitle = model.iconOnly ? nil : model.badge
1514
1536
  configuration.imagePadding = model.iconOnly ? 0 : 8
1515
- accessoryButton.configuration = configuration
1516
- accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1517
- accessoryButton.accessibilityValue = model.badge
1518
- accessoryButton.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1537
+ for button in [accessoryButton, shelfAccessoryButton] {
1538
+ button.configuration = configuration
1539
+ button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1540
+ button.accessibilityValue = model.badge
1541
+ button.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1542
+ }
1519
1543
  }
1520
1544
  #if compiler(>=6.2)
1521
1545
  if #available(iOS 26.0, *), model?.iconOnly != true {
@@ -1525,7 +1549,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1525
1549
  accessoryButton.removeFromSuperview()
1526
1550
  }
1527
1551
  if (model == nil) != (bottomAccessory == nil) {
1528
- bottomAccessory = model.map { _ in UITabAccessory(contentView: accessoryButton) }
1552
+ bottomAccessory = model.map { _ in UITabAccessory(contentView: shelfAccessoryButton) }
1529
1553
  }
1530
1554
  accessoryModel = model
1531
1555
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = 0 }
@@ -1549,7 +1573,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1549
1573
  accessoryButton.isHidden = model == nil
1550
1574
  accessoryGlass.isHidden = model == nil
1551
1575
  accessoryGlass.isUserInteractionEnabled = false
1552
- accessoryGlass.layer.cornerRadius = 24
1576
+ accessoryGlass.layer.cornerRadius = grouped ? 26 : 24
1553
1577
  accessoryGlass.clipsToBounds = true
1554
1578
  accessoryButton.backgroundColor = .clear
1555
1579
  accessoryButton.layer.cornerRadius = 24
@@ -1563,6 +1587,15 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1563
1587
  accessoryBadge.clipsToBounds = true
1564
1588
  accessoryBadge.isAccessibilityElement = false
1565
1589
  accessoryModel = model
1590
+ // The shared material must stay behind every button, even after a
1591
+ // companion-only update creates new controls or a shelf changes mode.
1592
+ if accessoryGlass.superview === view {
1593
+ view.bringSubviewToFront(accessoryGlass)
1594
+ for button in companionButtons { view.bringSubviewToFront(button) }
1595
+ view.bringSubviewToFront(accessoryButton)
1596
+ for separator in accessorySeparators { view.bringSubviewToFront(separator) }
1597
+ view.bringSubviewToFront(accessoryBadge)
1598
+ }
1566
1599
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
1567
1600
  view.setNeedsLayout()
1568
1601
  }
@@ -1586,14 +1619,30 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1586
1619
  let iconOnly = accessoryModel?.iconOnly == true
1587
1620
  let height: CGFloat = iconOnly ? 48 : 44
1588
1621
  let width: CGFloat = iconOnly ? 48 : max(0, view.bounds.width - 32)
1589
- let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
1622
+ let trailingX = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1623
+ ? view.safeAreaInsets.left + 20 : view.bounds.maxX - view.safeAreaInsets.right - width - 20
1624
+ let x = iconOnly ? trailingX : 16
1590
1625
  accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1591
1626
  accessoryGlass.frame = accessoryButton.frame
1627
+ let layout = accessoryModel?.accessoryLayout
1592
1628
  for (index, button) in companionButtons.enumerated() {
1593
- // Array order reads left to right, ending at the primary action.
1594
- button.frame = accessoryButton.frame.offsetBy(dx: -CGFloat(companionButtons.count - index) * (width + 12), dy: 0)
1629
+ let offset = CGFloat(companionButtons.count - index)
1630
+ if layout == "vertical" {
1631
+ button.frame = accessoryButton.frame.offsetBy(dx: 0, dy: -offset * height)
1632
+ } else {
1633
+ let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
1634
+ button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + (layout == nil ? 12 : 0)), dy: 0)
1635
+ }
1595
1636
  companionGlasses[index].frame = button.frame
1637
+ if layout != nil {
1638
+ accessoryGlass.frame = accessoryGlass.frame.union(button.frame)
1639
+ let line = 1 / (view.window?.screen.scale ?? 2)
1640
+ accessorySeparators[index].frame = layout == "vertical"
1641
+ ? CGRect(x: button.frame.minX + 12, y: button.frame.maxY - line / 2, width: width - 24, height: line)
1642
+ : CGRect(x: view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? button.frame.minX : button.frame.maxX - line / 2, y: button.frame.minY + 12, width: line, height: height - 24)
1643
+ }
1596
1644
  }
1645
+ if layout != nil { accessoryGlass.frame = accessoryGlass.frame.insetBy(dx: -2, dy: -2) }
1597
1646
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1598
1647
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1599
1648
  // A compact action floats over the page, not across its whole width.
@@ -2595,8 +2644,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
2595
2644
  // Craft's content swipe must not steal their drag/hold gestures.
2596
2645
  let point = gestureRecognizer.location(in: view)
2597
2646
  if !tabBar.isHidden, tabBar.frame.contains(point) { return false }
2598
- if !accessoryButton.isHidden, accessoryButton.window != nil,
2599
- accessoryButton.convert(accessoryButton.bounds, to: view).contains(point) { return false }
2647
+ if [accessoryButton, shelfAccessoryButton].contains(where: { !$0.isHidden && $0.window != nil && $0.convert($0.bounds, to: view).contains(point) }) { return false }
2648
+ if companionButtons.contains(where: { !$0.isHidden && $0.window != nil && $0.convert($0.bounds, to: view).contains(point) }) { return false }
2600
2649
  // A swipe may begin while the last one settles: it catches it.
2601
2650
  guard activeTabSwipeTarget == nil || tabSwipeSettling,
2602
2651
  hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
@@ -2007,8 +2007,9 @@ enum CraftSnapshots {
2007
2007
  // after a failure finds the previous value, because `pending`
2008
2008
  // was cleared above and the cache was only refilled on success.
2009
2009
  // Posting only on success left a failed checkpoint on screen as
2010
- // though it had been kept; Android already restores it
2011
- // (`changed("snapshots", name, if (ok) json else previous, null)`).
2010
+ // though it had been kept; Android restores it by sending what
2011
+ // a read gives once the write settles
2012
+ // (`changed("snapshots", name, current, null)`).
2012
2013
  NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
2013
2014
  completion?(ok)
2014
2015
  }
@@ -126,12 +126,21 @@ enum CraftNativeFiles {
126
126
  return url
127
127
  }
128
128
 
129
- /// A file name with no path in it.
129
+ /// A file name with no path in it that the disk will take. A long name
130
+ /// is cut to fit and keeps its extension, which is how a share sheet and
131
+ /// an upload know what the file is.
130
132
  static func safeName(_ name: String) -> String {
131
133
  let parts = name.components(separatedBy: CharacterSet(charactersIn: "/\\:\u{0}\r\n"))
132
134
  var cleaned = parts.joined(separator: "-").trimmingCharacters(in: .whitespacesAndNewlines)
133
135
  while cleaned.hasPrefix(".") { cleaned.removeFirst() }
134
- return cleaned.isEmpty ? "file" : String(cleaned.prefix(120))
136
+ // 255 UTF-16 units is what the file system takes, and 120 characters
137
+ // of flags or family emoji is more than that.
138
+ guard cleaned.count > 120 || cleaned.utf16.count > 255 else { return cleaned.isEmpty ? "file" : cleaned }
139
+ let ending = (cleaned as NSString).pathExtension
140
+ let suffix = ending.isEmpty || ending.count > 15 ? "" : "." + ending
141
+ var stem = String(cleaned.dropLast(suffix.count).prefix(120 - suffix.count))
142
+ while (stem + suffix).utf16.count > 255, !stem.isEmpty { stem.removeLast() }
143
+ return stem + suffix
135
144
  }
136
145
 
137
146
  static func describe(_ url: URL, type: String, extra: [String: Any] = [:]) -> [String: Any] {
@@ -857,6 +866,30 @@ enum CraftNativeFiles {
857
866
  }
858
867
  }
859
868
 
869
+ /// Base64 as `atob` reads it, which is what the browser host runs and
870
+ /// what Android is held to: the alphabet, ASCII white space anywhere,
871
+ /// padding optional but right when it is there, and nothing else.
872
+ /// Foundation alone wants the padding present and lets a third `=` by.
873
+ static func decodeBase64(_ text: String) -> Data? {
874
+ var data = [UInt8]()
875
+ data.reserveCapacity(text.utf8.count + 2)
876
+ var padding = 0
877
+ for byte in text.utf8 {
878
+ switch byte {
879
+ case 0x20, 0x09, 0x0A, 0x0C, 0x0D: continue
880
+ case UInt8(ascii: "="): padding += 1
881
+ case _ where padding > 0: return nil
882
+ case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"),
883
+ UInt8(ascii: "0")...UInt8(ascii: "9"), UInt8(ascii: "+"), UInt8(ascii: "/"):
884
+ data.append(byte)
885
+ default: return nil
886
+ }
887
+ }
888
+ guard padding <= 2, data.count % 4 != 1, padding == 0 || (data.count + padding) % 4 == 0 else { return nil }
889
+ data.append(contentsOf: repeatElement(UInt8(ascii: "="), count: (4 - data.count % 4) % 4))
890
+ return Data(base64Encoded: Data(data))
891
+ }
892
+
860
893
  /// What a screen made itself (a CSV export, a route it built) written as
861
894
  /// a file for the share sheet: `data` is text (`encoding: 'utf8'`, the
862
895
  /// default, so a CSV crosses the bridge once and no larger) or base64
@@ -874,7 +907,7 @@ enum CraftNativeFiles {
874
907
  case "utf8":
875
908
  bytes = Data(text.utf8)
876
909
  case "base64":
877
- guard let decoded = Data(base64Encoded: text.filter { !$0.isWhitespace }) else {
910
+ guard let decoded = decodeBase64(text) else {
878
911
  return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write was given base64 it could not read"))
879
912
  }
880
913
  bytes = decoded
@@ -9423,7 +9423,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9423
9423
  case ("Audio", "resume"):
9424
9424
  reply(.success(sounds.resume(id: id)))
9425
9425
  case ("Audio", "setVolume"):
9426
- reply(.success(sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)))
9426
+ // A missing or non-numeric volume used to arrive here as 0 — the
9427
+ // `number` helper's fallback — which silenced the sound and
9428
+ // answered true. Android refuses it and leaves the sound as it
9429
+ // was (`!volume.isFinite() || !fade.isFinite() || fade < 0`), so a
9430
+ // malformed call muted on one host and did nothing on the other.
9431
+ let fade = number(2, "fade")
9432
+ guard args.count > 1, let volume = craftNativeNumber(args[1])?.doubleValue,
9433
+ volume.isFinite, fade.isFinite, fade >= 0 else {
9434
+ reply(.success(false))
9435
+ return true
9436
+ }
9437
+ reply(.success(sounds.setVolume(id: id, volume: volume, fade: fade / 1000)))
9427
9438
  case ("Audio", "stop"):
9428
9439
  reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))
9429
9440
  case ("Audio", "stopAll"):
@@ -342,7 +342,19 @@ final class DriverTests: XCTestCase {
342
342
  }
343
343
 
344
344
  /// Deliver one gesture; the command's observation determines success.
345
- func press(_ element: XCUIElement) {
345
+ ///
346
+ /// Waits for the target to come to rest first (`settledFrame`) and returns
347
+ /// the frame it settled on. Every tap command goes through here, so a
348
+ /// control touched straight after a flick cannot be hit while the scroll
349
+ /// view is still decelerating — whichever command did the tapping. #449
350
+ /// settled only the boolean `tapid` branch, which left `tap`, `tapif`,
351
+ /// `tapexact` and non-boolean `tapid` with the same exposure.
352
+ ///
353
+ /// Waiting before the one gesture is not a second gesture, and for a
354
+ /// control already at rest this costs one 0.1s read.
355
+ @discardableResult
356
+ func press(_ element: XCUIElement) -> CGRect {
357
+ let settled = settledFrame(element)
346
358
  // Resolve this before activation: a menu item can disappear as soon
347
359
  // as its gesture completes (for example Select All).
348
360
  let input = [.textField, .secureTextField, .textView, .searchField].contains(element.elementType)
@@ -352,6 +364,7 @@ final class DriverTests: XCTestCase {
352
364
  // A tap on a read-only text view need not open the keyboard. Only a
353
365
  // following type command observes whether this target took focus.
354
366
  lastTappedInput = input
367
+ return settled
355
368
  }
356
369
 
357
370
  /// `target`'s frame once it has stopped moving, or its last reading at
@@ -541,8 +554,9 @@ final class DriverTests: XCTestCase {
541
554
  // renderer or hit-testing failure (HQ agreement job 7570).
542
555
  let before = target.value as? String
543
556
  let enabledBefore = target.isEnabled
544
- let frameBefore = settledFrame(target)
545
- press(target)
557
+ // press settles before it taps and hands back the frame it
558
+ // settled on, so this is one read rather than two.
559
+ let frameBefore = press(target)
546
560
  let settle = Date().addingTimeInterval(1.5)
547
561
  var flipped = false
548
562
  repeat {