@craft-native/ios 0.0.214 → 0.0.216

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 CHANGED
@@ -192,6 +192,14 @@ wrong screen. `systemtap` targets the system button around a Live Activity rathe
192
192
  than its remotely hosted text. The [Live Activity layout fixture](./fixtures/live-activity)
193
193
  exercises long names and timer alignment using the production widget.
194
194
 
195
+ Use `universallink https://example.com/profile` to test an associated HTTPS
196
+ link after a fresh install. The driver host uses Apple's public
197
+ `universalLinksOnly` option and waits up to 60 seconds for iOS to accept the
198
+ association downloaded from Apple's CDN. A rejected association fails the
199
+ journey; it cannot fall back to Safari. This operation does not launch the app
200
+ under test directly, so a preceding `terminate` still tests a cold handoff.
201
+ Use `openurl` for browser fallback checks and custom URL schemes.
202
+
195
203
  The iOS host uses the shared tree-layout ABI for eligible definite-size,
196
204
  non-grid, non-wrapped containers and retains a local fallback for older
197
205
  runtimes or unsupported styles. Both paths implement wrapped-line and grid
package/dist/index.js CHANGED
@@ -156,8 +156,11 @@ function serializeSimulatorSteps(steps) {
156
156
  }
157
157
  } else if ("name" in step)
158
158
  arg = token(step.name);
159
- else if ("url" in step)
159
+ else if ("url" in step) {
160
+ if (step.command === "universallink" && new URL(step.url).protocol !== "https:")
161
+ throw new Error("Universal Links require an HTTPS URL");
160
162
  arg = step.url;
163
+ }
161
164
  if (/[\r\n]/.test(arg))
162
165
  throw new Error("Simulator arguments cannot contain newlines");
163
166
  return `${step.command}${arg ? ` ${arg}` : ""}`;
package/dist/testing.d.ts CHANGED
@@ -60,6 +60,11 @@ export type SimulatorStep = {
60
60
  command: 'openurl';
61
61
  url: string;
62
62
  }
63
+ /** Use Apple's universalLinksOnly API, allowing association downloads up to 60 seconds. */
64
+ | {
65
+ command: 'universallink';
66
+ url: string;
67
+ }
63
68
  /** Runs `step` only when the element with this identifier is there (a screen shown the first time). */
64
69
  | {
65
70
  command: 'ifid';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.214",
3
+ "version": "0.0.216",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -308,7 +308,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
308
308
 
309
309
  func setContentContainerStyle(_ raw: [String: Any]?) {
310
310
  func nonNegative(_ value: Any?) -> CGFloat? {
311
- guard !(value is Bool), let number = value as? NSNumber else { return nil }
311
+ guard let number = craftNativeNumber(value) else { return nil }
312
312
  let points = CGFloat(truncating: number)
313
313
  guard points.isFinite, points >= 0 else { return nil }
314
314
  return points
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import CoreFoundation
2
3
  import AVFoundation
3
4
  import AVKit
4
5
  import CryptoKit
@@ -12,32 +13,41 @@ import UIKit
12
13
 
13
14
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
14
15
 
16
+ /// Foundation's `NSNumber(0/1) is Bool` is true even for JSON numbers.
17
+ /// Check the Core Foundation identity so real booleans stay invalid numeric
18
+ /// props while zero lengths, flex factors and opacity 1 remain numbers.
19
+ func craftNativeNumber(_ value: Any?) -> NSNumber? {
20
+ guard let number = value as? NSNumber,
21
+ CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil }
22
+ return number
23
+ }
24
+
15
25
  /// A finite number from a prop, for the views outside the screen controller,
16
26
  /// which has its own: the keyboard-avoiding view and SVG text read theirs
17
27
  /// with it, and without it the template did not compile.
18
28
  private func number(_ value: Any?) -> CGFloat? {
19
29
  // JavaScript booleans bridge to NSNumber on Apple platforms; they are
20
30
  // invalid numeric styles and must stay unset like Android's Number cast.
21
- guard !(value is Bool), let number = value as? NSNumber else { return nil }
31
+ guard let number = craftNativeNumber(value) else { return nil }
22
32
  let value = CGFloat(truncating: number)
23
33
  return value.isFinite ? value : nil
24
34
  }
25
35
 
26
36
  private func craftNativeFontWeight(_ value: Any?) -> String? {
27
37
  if let value = value as? String { return value }
28
- guard !(value is Bool), let number = value as? NSNumber, number.doubleValue.isFinite else { return nil }
38
+ guard let number = craftNativeNumber(value), number.doubleValue.isFinite else { return nil }
29
39
  let integer = number.doubleValue.rounded()
30
40
  return integer == number.doubleValue ? String(Int(integer)) : nil
31
41
  }
32
42
 
33
43
  private func craftNativePickerValue(_ value: Any?) -> String? {
34
44
  if let value = value as? String, !value.isEmpty { return value }
35
- if !(value is Bool), let value = value as? NSNumber, value.doubleValue.isFinite { return String(value.doubleValue) }
45
+ if let value = craftNativeNumber(value), value.doubleValue.isFinite { return String(value.doubleValue) }
36
46
  return nil
37
47
  }
38
48
 
39
49
  private func craftNativeGridColumnCount(_ value: Any?) -> Int {
40
- if !(value is Bool), let number = value as? NSNumber { return max(1, number.intValue) }
50
+ if let number = craftNativeNumber(value) { return max(1, number.intValue) }
41
51
  guard let text = value as? String else { return 1 }
42
52
  let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
43
53
  if trimmed.hasPrefix("repeat(") {
@@ -1387,7 +1397,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1387
1397
  }
1388
1398
 
1389
1399
  private static func numeric(_ value: Any?) -> Double? {
1390
- guard !(value is Bool), let number = value as? NSNumber else { return nil }
1400
+ guard let number = craftNativeNumber(value) else { return nil }
1391
1401
  let value = number.doubleValue
1392
1402
  return value.isFinite ? value : nil
1393
1403
  }
@@ -1458,7 +1468,7 @@ private enum CraftNativeLength: Equatable {
1458
1468
  init?(_ value: Any?) {
1459
1469
  // JavaScript booleans can bridge to NSNumber on Apple platforms;
1460
1470
  // they are invalid CSS lengths and must stay unset like Android.
1461
- if !(value is Bool), let number = value as? NSNumber {
1471
+ if let number = craftNativeNumber(value) {
1462
1472
  let points = CGFloat(truncating: number)
1463
1473
  guard points.isFinite else { return nil }
1464
1474
  self = .points(points)
@@ -1516,7 +1526,7 @@ private struct CraftNativeLayoutStyle {
1516
1526
 
1517
1527
  init(_ raw: [String: Any]) {
1518
1528
  func number(_ value: Any?) -> CGFloat? {
1519
- guard !(value is Bool), let number = value as? NSNumber else { return nil }
1529
+ guard let number = craftNativeNumber(value) else { return nil }
1520
1530
  let value = CGFloat(truncating: number)
1521
1531
  return value.isFinite ? value : nil
1522
1532
  }
@@ -5000,7 +5010,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5000
5010
  else if let symbol = spec["symbol"] as? String { button.setImage(UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal) }
5001
5011
  button.accessibilityIdentifier = spec["id"] as? String
5002
5012
  button.accessibilityLabel = spec["accessibilityLabel"] as? String
5003
- if !(spec["badge"] is Bool), let value = spec["badge"] as? NSNumber, value.intValue > 0 {
5013
+ if let value = craftNativeNumber(spec["badge"]), value.intValue > 0 {
5004
5014
  let dot = spec["badgeStyle"] as? String == "dot"
5005
5015
  let size: CGFloat = dot ? 9 : 18
5006
5016
  // The fill is a tinted symbol, not a background colour: on iOS 26
@@ -7658,7 +7668,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7658
7668
  /// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
7659
7669
  static func countdownDuration(_ value: Any?) -> TimeInterval? {
7660
7670
  let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
7661
- if let number = value as? NSNumber, !(value is Bool) {
7671
+ if let number = craftNativeNumber(value) {
7662
7672
  let seconds = number.doubleValue
7663
7673
  guard seconds.isFinite else { return nil }
7664
7674
  return min(maximum, max(0, seconds))
@@ -7679,7 +7689,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7679
7689
  /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
7680
7690
  /// milliseconds since 1970. Nil for anything else.
7681
7691
  static func pickerDate(_ value: Any?) -> Date? {
7682
- if let number = value as? NSNumber, !(value is Bool) {
7692
+ if let number = craftNativeNumber(value) {
7683
7693
  let milliseconds = number.doubleValue
7684
7694
  guard milliseconds.isFinite else { return nil }
7685
7695
  return Date(timeIntervalSince1970: milliseconds / 1000)
@@ -7851,7 +7861,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7851
7861
  }
7852
7862
 
7853
7863
  private func number(_ value: Any?) -> CGFloat? {
7854
- guard !(value is Bool), let number = value as? NSNumber else { return nil }
7864
+ guard let number = craftNativeNumber(value) else { return nil }
7855
7865
  let value = CGFloat(truncating: number)
7856
7866
  return value.isFinite ? value : nil
7857
7867
  }
@@ -14,6 +14,7 @@ import XCTest
14
14
  /// delay <ms> every proxied request waits this long (0 = off)
15
15
  /// exec <hook> runs the switch's HOOKS/<hook>.sh on the Mac (restart the API, …)
16
16
  /// openurl <url> opens a link through the system, answering "Open in the app?"
17
+ /// universallink <https-url> waits up to 60s for an OS-verified link; never opens a browser
17
18
  /// shot <name> screenshot → shots/<name>.png
18
19
  /// tree <name> accessibility tree → shots/<name>.txt
19
20
  /// tap <text> first hittable element whose label contains text
@@ -253,6 +254,21 @@ final class DriverTests: XCTestCase {
253
254
  answerOpenPrompt()
254
255
  note("openurl \(arg)")
255
256
  }
257
+ case "universallink":
258
+ guard let url = URL(string: arg), url.scheme == "https" else {
259
+ check(false, "universallink requires an HTTPS URL")
260
+ return
261
+ }
262
+ let host = XCUIApplication(bundleIdentifier: "{{BUNDLE_PREFIX}}.host")
263
+ host.launchArguments = ["--craft-universal-link", url.absoluteString]
264
+ host.launch()
265
+ let opened = app.wait(for: .runningForeground, timeout: 75)
266
+ if !opened {
267
+ post("/save?name=universal-link-host.txt", Data(host.debugDescription.utf8))
268
+ post("/save?name=universal-link-app.txt", Data(app.debugDescription.utf8))
269
+ }
270
+ check(opened, "universallink \(arg) opened the target app")
271
+ host.terminate()
256
272
  case "shot":
257
273
  let shot = XCUIScreen.main.screenshot()
258
274
  post("/save?name=\(arg).png", shot.pngRepresentation)
@@ -1,6 +1,36 @@
1
1
  import SwiftUI
2
+ import UIKit
2
3
 
3
4
  @main
4
5
  struct HostApp: App {
5
- var body: some Scene { WindowGroup { Text("Craft simulator driver") } }
6
+ @State private var status = "Craft simulator driver"
7
+
8
+ var body: some Scene {
9
+ WindowGroup {
10
+ Text(status).accessibilityIdentifier("craft-driver-status")
11
+ .task { await openUniversalLinkIfRequested() }
12
+ }
13
+ }
14
+
15
+ @MainActor
16
+ private func openUniversalLinkIfRequested() async {
17
+ let arguments = ProcessInfo.processInfo.arguments
18
+ guard let index = arguments.firstIndex(of: "--craft-universal-link"),
19
+ arguments.indices.contains(index + 1),
20
+ let url = URL(string: arguments[index + 1]), url.scheme == "https" else { return }
21
+ // Installation returns before iOS finishes downloading the AASA from
22
+ // Apple's CDN. Ask the public API until the OS accepts the association;
23
+ // universalLinksOnly prevents a failed attempt from opening Safari.
24
+ let deadline = Date().addingTimeInterval(60)
25
+ while Date() < deadline && !Task.isCancelled {
26
+ let opened = await UIApplication.shared.open(url, options: [.universalLinksOnly: true])
27
+ if opened {
28
+ status = "Universal Link accepted"
29
+ return
30
+ }
31
+ status = "Waiting for Universal Link association"
32
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
33
+ }
34
+ status = "Universal Link was not accepted within 60 seconds"
35
+ }
6
36
  }