@craft-native/ios 0.0.491 → 0.0.492
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 +1 -1
- package/templates/CraftApp.swift +5 -2
- package/templates/CraftHybrid.swift +60 -11
- package/templates/CraftNativeActions.swift +3 -2
- package/templates/CraftNativeFiles.swift +36 -3
- package/templates/CraftNativeScreen.swift +12 -1
- package/templates/testing/DriverTests.swift.template +17 -3
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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 ?? "
|
|
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
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
1594
|
-
|
|
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
|
|
2599
|
-
|
|
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
|
|
2011
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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 {
|