@craft-native/ios 0.0.467 → 0.0.475

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
@@ -1401,6 +1401,7 @@ function writeShell(output, config) {
1401
1401
  writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1402
1402
  writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1403
1403
  writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
1404
+ writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
1404
1405
  writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
1405
1406
  writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
1406
1407
  writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync2(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
package/dist/index.js CHANGED
@@ -1615,6 +1615,7 @@ function writeShell(output, config) {
1615
1615
  writeSource(join4(output, "Sources", "CraftVoiceRecording.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftVoiceRecording.swift"), "utf8"));
1616
1616
  writeSource(join4(output, "Sources", "CraftWebAudio.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftWebAudio.swift"), "utf8"));
1617
1617
  writeSource(join4(output, "Sources", "CraftHybrid.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftHybrid.swift"), "utf8"));
1618
+ writeSource(join4(output, "Sources", "CraftFramePacingLog.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftFramePacingLog.swift"), "utf8"));
1618
1619
  writeSource(join4(output, "Sources", "CraftCrashReporter.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftCrashReporter.swift"), "utf-8").replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION));
1619
1620
  writeSource(join4(output, "Sources", "CraftLiveActivities.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftLiveActivities.swift"), "utf8"));
1620
1621
  writeSource(join4(output, "Shared", "CraftActivityAttributes.swift"), readFileSync3(join4(TEMPLATES_DIR, "CraftActivityAttributes.swift"), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.467",
3
+ "version": "0.0.475",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -819,6 +819,7 @@ 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
+ var companions: [Tab] = []
822
823
  }
823
824
 
824
825
  @Published var tabs: [Tab] = []
@@ -828,8 +829,12 @@ final class CraftChrome: ObservableObject {
828
829
  /// The visible destination may be the accessory, independently of its owning stack.
829
830
  @Published var visiblePath: String?
830
831
  var accessorySelected: Bool {
831
- guard let path = visiblePath, let accessory = tabAccessory?.id else { return false }
832
- return path == accessory || path.hasPrefix(accessory + "/")
832
+ guard let accessory = tabAccessory else { return false }
833
+ return ([accessory] + accessory.companions).contains { accessorySelected($0) }
834
+ }
835
+ func accessorySelected(_ accessory: Tab) -> Bool {
836
+ guard let path = visiblePath else { return false }
837
+ return path == accessory.id || path.hasPrefix(accessory.id + "/")
833
838
  }
834
839
  var highlightedTab: String? { accessorySelected ? nil : selected }
835
840
  let tabSwipeProgress = CraftTabSwipeProgress()
@@ -954,7 +959,14 @@ final class CraftChrome: ObservableObject {
954
959
  next = Tab(id: path, title: title, symbol: item["symbol"] as? String ?? "message",
955
960
  badge: (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) },
956
961
  accessibilityLabel: item["accessibilityLabel"] as? String,
957
- iconOnly: item["iconOnly"] as? Bool ?? false)
962
+ iconOnly: item["iconOnly"] as? Bool ?? false,
963
+ companions: ((item["companions"] as? [[String: Any]]) ?? []).prefix(2).compactMap { action in
964
+ guard let companionPath = action["path"] as? String,
965
+ companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
966
+ let companionTitle = action["title"] as? String else { return nil }
967
+ return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
968
+ accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true)
969
+ })
958
970
  } else {
959
971
  next = nil
960
972
  }
@@ -1265,43 +1277,45 @@ private struct CraftTabBarContent: View {
1265
1277
  if !chrome.tabs.isEmpty {
1266
1278
  HStack(spacing: 8) {
1267
1279
  bar
1268
- if let accessory = chrome.tabAccessory {
1269
- Button { CraftHybrid.shared.open(accessory.id) } label: {
1270
- surface {
1271
- Image(systemName: accessory.symbol)
1272
- .font(.system(size: iconSize))
1273
- .foregroundStyle(chrome.accessorySelected ? selectedColor : Color.primary)
1274
- .frame(width: 62, height: 62)
1275
- .background {
1276
- if chrome.accessorySelected {
1277
- Circle().fill(selectedColor.opacity(0.14)).padding(4)
1280
+ if let primary = chrome.tabAccessory {
1281
+ ForEach(primary.companions + [primary]) { accessory in
1282
+ Button { CraftHybrid.shared.open(accessory.id) } label: {
1283
+ surface {
1284
+ Image(systemName: accessory.symbol)
1285
+ .font(.system(size: iconSize))
1286
+ .foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
1287
+ .frame(width: 62, height: 62)
1288
+ .background {
1289
+ if chrome.accessorySelected(accessory) {
1290
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1291
+ }
1292
+ }
1293
+ }
1294
+ .contentShape(Rectangle())
1295
+ .overlay(alignment: .topTrailing) {
1296
+ if let badge = accessory.badge {
1297
+ Text(badge)
1298
+ .font(.system(size: badgeSize, weight: .bold))
1299
+ .foregroundStyle(.white)
1300
+ .padding(.horizontal, 4)
1301
+ .frame(minWidth: 18, minHeight: 18)
1302
+ .background(Capsule().fill(Color.red))
1278
1303
  }
1279
1304
  }
1280
1305
  }
1281
- .contentShape(Rectangle())
1282
- .overlay(alignment: .topTrailing) {
1283
- if let badge = accessory.badge {
1284
- Text(badge)
1285
- .font(.system(size: badgeSize, weight: .bold))
1286
- .foregroundStyle(.white)
1287
- .padding(.horizontal, 4)
1288
- .frame(minWidth: 18, minHeight: 18)
1289
- .background(Capsule().fill(Color.red))
1290
- }
1291
- }
1292
- }
1293
- .buttonStyle(.plain)
1294
- .accessibilityShowsLargeContentViewer {
1295
- Image(systemName: accessory.symbol)
1296
- Text(accessory.accessibilityLabel ?? accessory.title)
1306
+ .buttonStyle(.plain)
1307
+ .accessibilityShowsLargeContentViewer {
1308
+ Image(systemName: accessory.symbol)
1309
+ Text(accessory.accessibilityLabel ?? accessory.title)
1310
+ }
1311
+ // Keep accessibility intent for the viewer; displayed
1312
+ // sizes come from the bounded metrics owned above.
1313
+ .environment(\.dynamicTypeSize, viewerTextSize)
1314
+ .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1315
+ .accessibilityValue(accessory.badge ?? "")
1316
+ .accessibilityIdentifier("craft-tab-accessory")
1317
+ .accessibilityAddTraits(chrome.accessorySelected(accessory) ? .isSelected : [])
1297
1318
  }
1298
- // Keep accessibility intent for the viewer; displayed
1299
- // sizes come from the bounded metrics owned above.
1300
- .environment(\.dynamicTypeSize, viewerTextSize)
1301
- .accessibilityLabel(accessory.accessibilityLabel ?? accessory.title)
1302
- .accessibilityValue(accessory.badge ?? "")
1303
- .accessibilityIdentifier("craft-tab-accessory")
1304
- .accessibilityAddTraits(chrome.accessorySelected ? .isSelected : [])
1305
1319
  }
1306
1320
  }
1307
1321
  .padding(.horizontal, 16)
@@ -0,0 +1,18 @@
1
+ import Foundation
2
+
3
+ /// The opt-in frame trace shares one append-only file for this app process.
4
+ enum CraftFramePacingLog {
5
+ static func open(in directory: URL) throws -> FileHandle {
6
+ let manager = FileManager.default
7
+ try manager.createDirectory(at: directory, withIntermediateDirectories: true)
8
+ let url = directory.appendingPathComponent("craft-frame-pacing.log")
9
+ if !manager.fileExists(atPath: url.path),
10
+ !manager.createFile(atPath: url.path, contents: nil) {
11
+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteUnknownError,
12
+ userInfo: [NSFilePathErrorKey: url.path])
13
+ }
14
+ let handle = try FileHandle(forWritingTo: url)
15
+ _ = try handle.seekToEnd()
16
+ return handle
17
+ }
18
+ }
@@ -1192,6 +1192,9 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1192
1192
  private var synchronizingTabs = false
1193
1193
  private var accessoryModel: CraftChrome.Tab?
1194
1194
  private let accessoryButton = UIButton(type: .system)
1195
+ private var companionButtons: [UIButton] = []
1196
+ private var companionModels: [CraftChrome.Tab] = []
1197
+ private var companionGlasses: [UIVisualEffectView] = []
1195
1198
  private let accessoryBadge = UILabel()
1196
1199
  private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1197
1200
  private static let tabPictures = NSCache<NSURL, UIImage>()
@@ -1410,6 +1413,40 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1410
1413
  private func updateSystemAccessory(hidden: Bool) {
1411
1414
  let chrome = CraftChrome.shared
1412
1415
  let model = hidden ? nil : chrome.tabAccessory
1416
+ // Keep controls when only the selected destination or unread badge changes.
1417
+ let companions = model?.iconOnly == true ? model?.companions ?? [] : []
1418
+ if companionModels != companions {
1419
+ companionModels = companions
1420
+ for button in companionButtons { button.removeFromSuperview() }
1421
+ for glass in companionGlasses { glass.removeFromSuperview() }
1422
+ companionButtons.removeAll()
1423
+ companionGlasses.removeAll()
1424
+ for action in companions {
1425
+ let button = UIButton(type: .system)
1426
+ var configuration = UIButton.Configuration.plain()
1427
+ configuration.image = UIImage(systemName: action.symbol)
1428
+ button.configuration = configuration
1429
+ button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1430
+ button.accessibilityLabel = action.accessibilityLabel ?? action.title
1431
+ button.accessibilityValue = action.badge
1432
+ button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1433
+ button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1434
+ let glass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1435
+ #if compiler(>=6.2)
1436
+ if #available(iOS 26.0, *) { glass.effect = UIGlassEffect() }
1437
+ #endif
1438
+ glass.layer.cornerRadius = 24
1439
+ glass.clipsToBounds = true
1440
+ glass.isUserInteractionEnabled = false
1441
+ view.addSubview(glass)
1442
+ view.addSubview(button)
1443
+ companionGlasses.append(glass)
1444
+ companionButtons.append(button)
1445
+ }
1446
+ }
1447
+ for (index, action) in companionModels.enumerated() {
1448
+ companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1449
+ }
1413
1450
  if let model {
1414
1451
  var configuration = UIButton.Configuration.plain()
1415
1452
  configuration.image = UIImage(systemName: model.symbol)
@@ -1419,7 +1456,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1419
1456
  accessoryButton.configuration = configuration
1420
1457
  accessoryButton.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1421
1458
  accessoryButton.accessibilityValue = model.badge
1422
- accessoryButton.accessibilityTraits = chrome.accessorySelected ? [.button, .selected] : [.button]
1459
+ accessoryButton.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1423
1460
  }
1424
1461
  #if compiler(>=6.2)
1425
1462
  if #available(iOS 26.0, *), model?.iconOnly != true {
@@ -1493,6 +1530,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1493
1530
  let x = iconOnly ? view.bounds.maxX - view.safeAreaInsets.right - width - 20 : 16
1494
1531
  accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1495
1532
  accessoryGlass.frame = accessoryButton.frame
1533
+ for (index, button) in companionButtons.enumerated() {
1534
+ // Array order reads left to right, ending at the primary action.
1535
+ button.frame = accessoryButton.frame.offsetBy(dx: -CGFloat(companionButtons.count - index) * (width + 12), dy: 0)
1536
+ companionGlasses[index].frame = button.frame
1537
+ }
1496
1538
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1497
1539
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1498
1540
  // A compact action floats over the page, not across its whole width.
@@ -2703,11 +2745,11 @@ final class CraftFramePacing: NSObject {
2703
2745
  private let output = DispatchQueue(label: "craft.frame-pacing", qos: .utility)
2704
2746
  private lazy var file: FileHandle? = {
2705
2747
  guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
2706
- let url = caches.appendingPathComponent("craft-frame-pacing.log")
2707
- if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
2708
- let handle = try? FileHandle(forWritingTo: url)
2709
- _ = try? handle?.seekToEnd()
2710
- return handle
2748
+ do { return try CraftFramePacingLog.open(in: caches) }
2749
+ catch {
2750
+ NSLog("[craft frames] trace file unavailable: %@", String(describing: error))
2751
+ return nil
2752
+ }
2711
2753
  }()
2712
2754
 
2713
2755
  private func write(_ line: String) {
@@ -10779,6 +10779,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10779
10779
  // beneath the screen's floating chrome.
10780
10780
  let overflow = style["overflow"] as? String
10781
10781
  view.clipsToBounds = overflow == "hidden"
10782
+ // A single-line editor owns a text viewport. Its private label
10783
+ // can be wider than the control while editing or showing a URL;
10784
+ // inherited visible overflow must not open that viewport.
10785
+ || view is UITextField
10782
10786
  || (view is UIScrollView && overflow != "visible")
10783
10787
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
10784
10788
  || ((view is UIImageView || view is CraftNativePositionedImageView) && (borderRadius > 0 || hasCornerRadii))
@@ -11440,6 +11444,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11440
11444
  // UISwitch also owns its native identity and enabled/on state.
11441
11445
  // Assigning .button changes its legacy AX type into a button while
11442
11446
  // modern XCTest still identifies its UIKit class as a switch.
11447
+ // The solid variant publishes "1"/"0" as a string from
11448
+ // refreshAccessibility(). A UISwitch left to UIKit does not: the value
11449
+ // it hands XCTest arrives as a number, so every `value as? String`
11450
+ // read finds nothing. The driver's flip check is one of those reads,
11451
+ // which is why a switch that did flip was reported as never flipping,
11452
+ // and only sometimes - it depends which representation the accessibility
11453
+ // snapshot was built from. Publishing the string makes both variants
11454
+ // answer the same way. An authored accessibilityValue still wins.
11455
+ if let toggle = view as? UISwitch, !authoredAccessibilityValues.contains(id) {
11456
+ view.accessibilityValue = toggle.isOn ? "1" : "0"
11457
+ }
11443
11458
  if view is UITextView || view is UISwitch { return }
11444
11459
  if let toggle = view as? CraftNativeSolidSwitch { toggle.refreshAccessibility(); return }
11445
11460
  view.accessibilityTraits = traits
@@ -30,14 +30,22 @@ enum CraftPresenter {
30
30
  }
31
31
 
32
32
  /// Present over whatever is on screen. A controller that shows as a
33
- /// popover on iPad (an action sheet, a share sheet) points at `anchor` in
34
- /// `view`, or sits in the middle of it with no arrow; iPadOS raises an
35
- /// exception for a popover with neither. False when nothing can present,
36
- /// so the caller answers rather than waiting.
33
+ /// popover (an action sheet, a share sheet, on a regular-width screen)
34
+ /// points at `anchor` in `view`, or sits in the middle of it with no
35
+ /// arrow; iPadOS raises an exception for a popover with neither. False
36
+ /// when nothing can present, so the caller answers rather than waiting.
37
+ ///
38
+ /// Only where the presentation really is a popover. A `.actionSheet`
39
+ /// always carries a `popoverPresentationController`, phone included, and
40
+ /// from iOS 26 configuring it is enough to make the phone present the
41
+ /// sheet as a centred popover -- which silently drops its cancel action,
42
+ /// as UIKit never draws one in a popover. A compact screen is left alone
43
+ /// and gets the standard sheet, with its Cancel.
37
44
  @discardableResult
38
45
  static func present(_ controller: UIViewController, from view: UIView?, anchor: CGRect? = nil) -> Bool {
39
46
  guard let top = topViewController() else { return false }
40
- if let popover = controller.popoverPresentationController {
47
+ let asPopover = (view ?? top.view)?.traitCollection.horizontalSizeClass == .regular
48
+ if asPopover, let popover = controller.popoverPresentationController {
41
49
  let source = view ?? top.view!
42
50
  popover.sourceView = source
43
51
  if let anchor {
@@ -182,6 +182,30 @@ final class DriverTests: XCTestCase {
182
182
  return nil
183
183
  }
184
184
 
185
+ /// Safari's web view extends behind its browser bars. Those regions accept
186
+ /// touches for the bars, not for page controls reported inside the web view.
187
+ func browserContentFrame(_ frame: CGRect, topBar: CGRect?, bottomBar: CGRect?) -> CGRect {
188
+ var top = frame.minY
189
+ var bottom = frame.maxY
190
+ if let bar = topBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
191
+ top = max(top, bar.maxY)
192
+ }
193
+ if let bar = bottomBar, bar.contains(CGPoint(x: frame.midX, y: bar.midY)), bar.intersects(frame) {
194
+ bottom = min(bottom, bar.minY)
195
+ }
196
+ return CGRect(x: frame.minX, y: top, width: frame.width, height: max(0, bottom - top))
197
+ }
198
+
199
+ func scrollViewport() -> CGRect {
200
+ let webView = app.webViews.firstMatch
201
+ let topBar = app.otherElements["TopBrowserBar"]
202
+ let bottomBar = app.toolbars["BottomBrowserToolbar"]
203
+ guard webView.exists, topBar.exists || bottomBar.exists else { return app.frame }
204
+ return browserContentFrame(webView.frame.intersection(app.frame),
205
+ topBar: topBar.exists ? topBar.frame : nil,
206
+ bottomBar: bottomBar.exists ? bottomBar.frame : nil)
207
+ }
208
+
185
209
  /// A short target fits entirely; an oversized target fills the viewport.
186
210
  func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
187
211
  let visible = target.intersection(viewport)
@@ -192,7 +216,7 @@ final class DriverTests: XCTestCase {
192
216
 
193
217
  func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
194
218
  guard let target, target.exists, target.isHittable,
195
- visibleScrollFrame(target.frame, in: app.frame) else { return false }
219
+ visibleScrollFrame(target.frame, in: scrollViewport()) else { return false }
196
220
  // Hittable alone accepts controls clipped by the scroll view's edge.
197
221
  // Only an ancestor scroll view clips this target, not its siblings.
198
222
  // One that does not overlap the target is not its ancestor, whatever
@@ -220,7 +244,7 @@ final class DriverTests: XCTestCase {
220
244
  /// below the screen while drawing it over the lower half - and it stops
221
245
  /// short of a keyboard whose frame is known.
222
246
  func scrollPage(towardTop: Bool) {
223
- let frame = app.frame
247
+ let frame = scrollViewport()
224
248
  var lower = frame.minY + frame.height * 0.55
225
249
  let keyboard = app.keyboards.firstMatch
226
250
  if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
@@ -719,7 +743,7 @@ final class DriverTests: XCTestCase {
719
743
  var found = element(containing: arg)
720
744
  var drags = 0
721
745
  while !scrollTargetVisible(found) && drags < 12 {
722
- let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
746
+ let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < scrollViewport().midY } ?? false
723
747
  scrollPage(towardTop: towardTop)
724
748
  drags += 1
725
749
  found = element(containing: arg)