@craft-native/ios 0.0.237 → 0.0.241

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.
@@ -139,7 +139,7 @@ private enum CraftNativeTreeLayoutBridge {
139
139
  var alignItems: UInt32 = 3
140
140
  var alignSelf: UInt32 = 0
141
141
  var flexWrap: UInt32 = 0
142
- var alignContent: UInt32 = 6
142
+ var alignContent: UInt32 = 5
143
143
  var marginTop: Float = 0
144
144
  var marginRight: Float = 0
145
145
  var marginBottom: Float = 0
@@ -180,7 +180,7 @@ private enum CraftNativeTreeLayoutBridge {
180
180
  var alignItems: UInt32 = 3
181
181
  var alignSelf: UInt32 = 0
182
182
  var flexWrap: UInt32 = 0
183
- var alignContent: UInt32 = 6
183
+ var alignContent: UInt32 = 5
184
184
  var marginTop: Float = 0
185
185
  var marginRight: Float = 0
186
186
  var marginBottom: Float = 0
@@ -222,7 +222,7 @@ private enum CraftNativeTreeLayoutBridge {
222
222
  var alignItems: UInt32 = 3
223
223
  var alignSelf: UInt32 = 0
224
224
  var flexWrap: UInt32 = 0
225
- var alignContent: UInt32 = 6
225
+ var alignContent: UInt32 = 5
226
226
  var marginTop: Float = 0
227
227
  var marginRight: Float = 0
228
228
  var marginBottom: Float = 0
@@ -1171,8 +1171,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1171
1171
  private var authoredHeading: Double?
1172
1172
  private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1173
1173
  private var markerBearings: [String: Double] = [:]
1174
+ private var markerPinColors: [String: UIColor] = [:]
1174
1175
  private var tileOverlay: MKTileOverlay?
1175
1176
  private var tileTemplate: String?
1177
+ private var tileAttributionBottom: NSLayoutConstraint?
1176
1178
  private lazy var tileAttribution: UILabel = {
1177
1179
  let label = UILabel()
1178
1180
  label.font = .systemFont(ofSize: 9)
@@ -1181,7 +1183,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1181
1183
  label.translatesAutoresizingMaskIntoConstraints = false
1182
1184
  label.numberOfLines = 2
1183
1185
  addSubview(label)
1184
- NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
1186
+ let bottom = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8)
1187
+ tileAttributionBottom = bottom
1188
+ NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), bottom, label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
1185
1189
  return label
1186
1190
  }()
1187
1191
 
@@ -1206,6 +1210,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1206
1210
  isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
1207
1211
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
1208
1212
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1213
+ isPitchEnabled = props["pitchEnabled"] as? Bool ?? true
1209
1214
  mapType = Self.mapType(props["mapType"] as? String)
1210
1215
  configureTiles(props["tileOverlay"] as? [String: Any])
1211
1216
  showsCompass = props["showsCompass"] as? Bool ?? false
@@ -1219,6 +1224,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1219
1224
  }
1220
1225
  edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
1221
1226
  var nextBearings: [String: Double] = [:]
1227
+ var nextPinColors: [String: UIColor] = [:]
1222
1228
  // Showing the user dot must not trigger a permission prompt from a
1223
1229
  // renderer pass. The app's existing location capability owns that
1224
1230
  // prompt; a map only opts in after authorization is already granted.
@@ -1227,6 +1233,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1227
1233
  var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
1228
1234
  var nextHandlers: [String: String] = [:]
1229
1235
  var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
1236
+ var nextPolylineIDs: [String] = []
1230
1237
  for (index, child) in children.enumerated() {
1231
1238
  guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
1232
1239
  let type = rawType.lowercased()
@@ -1239,6 +1246,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1239
1246
  if type == "mapmarker" || type == "marker" {
1240
1247
  guard let point = points.first else { continue }
1241
1248
  nextMarkers[id] = (point, childProps["title"] as? String)
1249
+ if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
1242
1250
  if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
1243
1251
  nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
1244
1252
  }
@@ -1251,11 +1259,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1251
1259
  let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 3
1252
1260
  let width = CGFloat(authoredWidth)
1253
1261
  guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
1262
+ if nextPolylines[id] == nil { nextPolylineIDs.append(id) }
1254
1263
  nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
1255
1264
  }
1256
1265
  }
1257
1266
 
1258
1267
  markerBearings = nextBearings
1268
+ markerPinColors = nextPinColors
1259
1269
  let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
1260
1270
  removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
1261
1271
  for (id, value) in nextMarkers {
@@ -1282,7 +1292,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1282
1292
  }
1283
1293
  overlayCoordinatesByID.removeValue(forKey: id)
1284
1294
  }
1285
- for (id, value) in nextPolylines {
1295
+ for id in nextPolylineIDs {
1296
+ guard let value = nextPolylines[id] else { continue }
1286
1297
  if let overlay = overlaysByID[id],
1287
1298
  let coordinates = overlayCoordinatesByID[id],
1288
1299
  Self.coordinatesEqual(coordinates, value.coordinates),
@@ -1302,6 +1313,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1302
1313
  overlayWidths[ObjectIdentifier(overlay)] = value.width
1303
1314
  addOverlay(overlay)
1304
1315
  }
1316
+ // Keep authored casing/foreground order when an earlier line changes
1317
+ // or retained children move. Exchanging native overlays preserves the
1318
+ // unchanged objects, tile levels and current camera.
1319
+ let ownedOverlayIDs = Set(overlaysByID.values.map { ObjectIdentifier($0) })
1320
+ var orderedOverlays = overlays.compactMap { $0 as? MKPolyline }
1321
+ .filter { ownedOverlayIDs.contains(ObjectIdentifier($0)) }
1322
+ for (index, id) in nextPolylineIDs.enumerated() {
1323
+ guard let overlay = overlaysByID[id], index < orderedOverlays.count,
1324
+ orderedOverlays[index] !== overlay,
1325
+ let previousIndex = orderedOverlays.firstIndex(where: { $0 === overlay }) else { continue }
1326
+ exchangeOverlay(orderedOverlays[index], with: orderedOverlays[previousIndex])
1327
+ orderedOverlays.swapAt(index, previousIndex)
1328
+ }
1305
1329
 
1306
1330
  if let region = Self.region(props["region"]) {
1307
1331
  // `region` is controlled: an authored change is allowed to move
@@ -1411,20 +1435,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1411
1435
 
1412
1436
  private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1413
1437
  if let bearing = markerBearings[id] {
1414
- let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
1438
+ let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
1415
1439
  .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1416
- view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
1440
+ // MapKit otherwise tints this as a monochrome template and loses
1441
+ // the contrasting route arrow when displayed over map content.
1442
+ view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)?
1443
+ .withRenderingMode(.alwaysOriginal)
1417
1444
  view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1418
1445
  } else {
1446
+ view.tintColor = markerPinColors[id] ?? .systemRed
1419
1447
  view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1420
- .withTintColor(.systemRed, renderingMode: .alwaysOriginal)
1448
+ .withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
1421
1449
  view.transform = .identity
1422
1450
  }
1423
1451
  }
1424
1452
 
1425
1453
  override func layoutSubviews() {
1426
1454
  super.layoutSubviews()
1427
- if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
1455
+ if tileTemplate != nil {
1456
+ tileAttributionBottom?.constant = -min(edgePadding.bottom, max(0, bounds.height - 40)) - 8
1457
+ bringSubviewToFront(tileAttribution)
1458
+ }
1428
1459
  if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
1429
1460
  pendingRegion = nil
1430
1461
  setRegion(region, animated: false)
@@ -2681,11 +2712,24 @@ private class CraftNativeFlowView: UIStackView {
2681
2712
  var fixedHeight = style.top != nil && style.bottom != nil
2682
2713
  ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
2683
2714
  : style.height?.resolve(content.height)
2715
+ let authoredWidth = fixedWidth != nil
2716
+ let authoredHeight = fixedHeight != nil
2684
2717
  (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
2685
- let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
2686
- let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
2687
- let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
2688
- frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: measured.width, height: measured.height)))
2718
+ var measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
2719
+ var boundedWidth = clamp(measured.width, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width))
2720
+ let proposedHeight = !authoredHeight && style.aspectRatio != nil ? boundedWidth / style.aspectRatio! : fixedHeight
2721
+ if abs(boundedWidth - measured.width) > 0.0001 || proposedHeight != fixedHeight {
2722
+ // A narrower label or container must wrap before its
2723
+ // height and bottom anchor are resolved.
2724
+ measured = CraftNativeFlexLayout.measure(child, width: boundedWidth, height: proposedHeight, maxWidth: content.width, maxHeight: content.height)
2725
+ }
2726
+ let boundedHeight = clamp(measured.height, style.minHeight?.resolve(content.height), style.maxHeight?.resolve(content.height))
2727
+ if !authoredWidth, authoredHeight, let ratio = style.aspectRatio {
2728
+ boundedWidth = clamp(boundedHeight * ratio, style.minWidth?.resolve(content.width), style.maxWidth?.resolve(content.width))
2729
+ }
2730
+ let x = style.left ?? (style.right.map { content.width - $0 - boundedWidth } ?? 0)
2731
+ let y = style.top ?? (style.bottom.map { content.height - $0 - boundedHeight } ?? 0)
2732
+ frames.append((child, CGRect(x: content.minX + x, y: content.minY + y, width: boundedWidth, height: boundedHeight)))
2689
2733
  }
2690
2734
  }
2691
2735
  return (size, frames)
@@ -3370,6 +3414,8 @@ private final class CraftNativeModalView: UIView {
3370
3414
  let generation = animationGeneration
3371
3415
  layer.removeAllAnimations()
3372
3416
  guard visible else {
3417
+ craftScreenController?.forgetFocusedInput(in: self)
3418
+ endEditing(true)
3373
3419
  if animationType == "slide" && wasVisible {
3374
3420
  let distance = max(bounds.height, superview?.bounds.height ?? UIScreen.main.bounds.height)
3375
3421
  UIView.animate(withDuration: 0.24, animations: {
@@ -3409,6 +3455,8 @@ private final class CraftNativeModalView: UIView {
3409
3455
  }
3410
3456
 
3411
3457
  func resetForTeardown() {
3458
+ craftScreenController?.forgetFocusedInput(in: self)
3459
+ endEditing(true)
3412
3460
  animationGeneration += 1
3413
3461
  layer.removeAllAnimations()
3414
3462
  isHidden = true
@@ -4326,6 +4374,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4326
4374
  func forgetFocusedInput(_ input: UIView? = nil) {
4327
4375
  if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
4328
4376
  }
4377
+ func forgetFocusedInput(in container: UIView) {
4378
+ if let input = lastFocusedInput, input === container || input.isDescendant(of: container) {
4379
+ lastFocusedInput = nil
4380
+ }
4381
+ }
4329
4382
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
4330
4383
  private var flatListOwners: [String: String] = [:]
4331
4384
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
@@ -4797,7 +4850,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4797
4850
  CraftNativeConsole.write(level, category: category, message: message)
4798
4851
  }
4799
4852
  let storageGet: @convention(block) (String) -> String? = { key in
4800
- CraftNativeActions.storageJSON(forKey: key)
4853
+ do { return try CraftNativeActions.storageJSON(forKey: key) }
4854
+ catch {
4855
+ if let context = JSContext.current() {
4856
+ context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
4857
+ }
4858
+ return nil
4859
+ }
4801
4860
  }
4802
4861
  let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
4803
4862
  CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
@@ -6048,6 +6107,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6048
6107
  private func restoreFocus(_ focused: UIView?) {
6049
6108
  guard let focused, focused.window != nil, !focused.isFirstResponder,
6050
6109
  lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
6110
+ var ancestor: UIView? = focused
6111
+ while let view = ancestor {
6112
+ if view.isHidden { return }
6113
+ ancestor = view.superview
6114
+ }
6051
6115
  // A delayed acknowledgement belongs to the input that scheduled it.
6052
6116
  // A newer field or an explicit dismissal owns focus now.
6053
6117
  _ = focused.becomeFirstResponder()
@@ -7497,13 +7561,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7497
7561
  .font: font,
7498
7562
  ]
7499
7563
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
7564
+ // UIKit otherwise resets the field's alignment to natural when these attributes are applied.
7565
+ let paragraph = NSMutableParagraphStyle()
7566
+ paragraph.alignment = alignment
7500
7567
  if let lineHeight = number(style["lineHeight"]) {
7501
- let paragraph = NSMutableParagraphStyle()
7502
7568
  paragraph.minimumLineHeight = lineHeight
7503
7569
  paragraph.maximumLineHeight = lineHeight
7504
- paragraph.alignment = alignment
7505
- attributes[.paragraphStyle] = paragraph
7506
7570
  }
7571
+ attributes[.paragraphStyle] = paragraph
7507
7572
  addTextDecorationAttributes(style, to: &attributes)
7508
7573
  if let shadow = textShadow(style) { attributes[.shadow] = shadow }
7509
7574
  return attributes
@@ -0,0 +1,99 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import CoreFoundation
4
+
5
+ /// The existing Storage API's durable, process-wide serialized backing store.
6
+ /// Tombstones prevent an old UserDefaults value from reappearing after removal.
7
+ enum CraftPersistentStorage {
8
+ private static let lock = NSRecursiveLock()
9
+ private static var directory: URL {
10
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("craft-native-storage")
11
+ }
12
+ private static func path(_ key: String, directory: URL) -> URL {
13
+ let digest = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
14
+ return directory.appendingPathComponent(digest + ".json")
15
+ }
16
+ static func get(_ key: String, prefix: String, storageDirectory: URL? = nil) throws -> Data? {
17
+ lock.lock(); defer { lock.unlock() }
18
+ let source = path(key, directory: storageDirectory ?? directory)
19
+ if FileManager.default.fileExists(atPath: source.path) {
20
+ guard let saved = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], saved["key"] as? String == key else {
21
+ throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
22
+ }
23
+ if saved["deleted"] as? Bool == true { return nil }
24
+ guard let value = saved["value"] else { throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Missing stored value") }
25
+ return try JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed)
26
+ }
27
+ guard let legacy = UserDefaults.standard.data(forKey: prefix + key) else { return nil }
28
+ // Only valid legacy JSON qualifies for deferred migration on I/O failure.
29
+ _ = try JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)
30
+ // Reading a valid legacy checkpoint must survive a full disk. Migration
31
+ // can be retried on a later read; only a successful set promises a new
32
+ // durable checkpoint and permits the application to acknowledge it.
33
+ do { try set(key, data: legacy, prefix: prefix, storageDirectory: storageDirectory) }
34
+ catch { print("Unable to migrate an existing storage value: \(error)") }
35
+ return legacy
36
+ }
37
+ static func set(_ key: String, data: Data?, prefix: String, storageDirectory: URL? = nil, writer: CraftRecordingArchive.Writer = CraftRecordingArchive.writeAtomically) throws {
38
+ lock.lock(); defer { lock.unlock() }
39
+ var entry: [String: Any] = ["key": key, "deleted": data == nil]
40
+ if let data { entry["value"] = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) }
41
+ try writer(JSONSerialization.data(withJSONObject: entry), path(key, directory: storageDirectory ?? directory))
42
+ if let data { UserDefaults.standard.set(data, forKey: prefix + key) }
43
+ else { UserDefaults.standard.removeObject(forKey: prefix + key) }
44
+ }
45
+
46
+ static func jsonEqual(_ left: Any, _ right: Any) -> Bool {
47
+ if left is NSNull { return right is NSNull }
48
+ if let a = left as? NSNumber, let b = right as? NSNumber {
49
+ let aBoolean = CFGetTypeID(a) == CFBooleanGetTypeID()
50
+ let bBoolean = CFGetTypeID(b) == CFBooleanGetTypeID()
51
+ return aBoolean == bBoolean && a.compare(b) == .orderedSame
52
+ }
53
+ if let a = left as? String, let b = right as? String { return a == b }
54
+ if let a = left as? [Any], let b = right as? [Any] {
55
+ return a.count == b.count && zip(a, b).allSatisfy { jsonEqual($0.0, $0.1) }
56
+ }
57
+ if let a = left as? [String: Any], let b = right as? [String: Any] {
58
+ return a.count == b.count && a.allSatisfy { key, value in b[key].map { jsonEqual(value, $0) } ?? false }
59
+ }
60
+ return false
61
+ }
62
+
63
+ /// An existing-key conditional checkpoint, serialized with every reader
64
+ /// and writer. A deletion that reached this store first can never recreate.
65
+ static func compareAndSet(_ key: String, expected: Any, value: Any, prefix: String, storageDirectory: URL? = nil, writer: CraftRecordingArchive.Writer = CraftRecordingArchive.writeAtomically) throws -> Bool {
66
+ lock.lock(); defer { lock.unlock() }
67
+ guard let data = try get(key, prefix: prefix, storageDirectory: storageDirectory) else { return false }
68
+ let current = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
69
+ guard jsonEqual(current, expected) else { return false }
70
+ let next = try JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed)
71
+ try set(key, data: next, prefix: prefix, storageDirectory: storageDirectory, writer: writer)
72
+ return true
73
+ }
74
+
75
+ static func setIfAbsent(_ key: String, value: Any, prefix: String, storageDirectory: URL? = nil, writer: CraftRecordingArchive.Writer = CraftRecordingArchive.writeAtomically) throws -> Bool {
76
+ lock.lock(); defer { lock.unlock() }
77
+ guard try get(key, prefix: prefix, storageDirectory: storageDirectory) == nil else { return false }
78
+ let next = try JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed)
79
+ try set(key, data: next, prefix: prefix, storageDirectory: storageDirectory, writer: writer)
80
+ return true
81
+ }
82
+ static func keys(prefix: String) throws -> [String] {
83
+ lock.lock(); defer { lock.unlock() }
84
+ var result = Set(UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
85
+ if FileManager.default.fileExists(atPath: directory.path) {
86
+ for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
87
+ guard let entry = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], let key = entry["key"] as? String else {
88
+ throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
89
+ }
90
+ if entry["deleted"] as? Bool == true { result.remove(key) } else { result.insert(key) }
91
+ }
92
+ }
93
+ return result.sorted()
94
+ }
95
+ static func clear(prefix: String) throws {
96
+ lock.lock(); defer { lock.unlock() }
97
+ for key in try keys(prefix: prefix) { try set(key, data: nil, prefix: prefix) }
98
+ }
99
+ }
@@ -0,0 +1,85 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import Darwin
4
+
5
+ /// Immutable completed recordings and durable acknowledgement tombstones.
6
+ /// IDs are opaque values; their SHA-256, never caller path text, names a file.
7
+ enum CraftRecordingArchive {
8
+ typealias Writer = (Data, URL) throws -> Void
9
+ static func writeAtomically(_ data: Data, _ url: URL) throws {
10
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
11
+ let temporary = url.deletingLastPathComponent().appendingPathComponent(".craft-atomic-" + UUID().uuidString)
12
+ defer { try? FileManager.default.removeItem(at: temporary) }
13
+ try data.write(to: temporary, options: .withoutOverwriting)
14
+ let file = try FileHandle(forWritingTo: temporary)
15
+ do { try file.synchronize(); try file.close() }
16
+ catch { try? file.close(); throw error }
17
+ try FileManager.default.setAttributes([.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], ofItemAtPath: temporary.path)
18
+ var protected = temporary
19
+ var values = URLResourceValues()
20
+ values.isExcludedFromBackup = true
21
+ try protected.setResourceValues(values)
22
+ let result = temporary.path.withCString { source in url.path.withCString { destination in Darwin.rename(source, destination) } }
23
+ if result != 0 { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) }
24
+ }
25
+
26
+ private static func url(_ directory: URL, _ kind: String, _ id: String, acknowledged: Bool = false) -> URL {
27
+ let digest = SHA256.hash(data: Data(id.utf8)).map { String(format: "%02x", $0) }.joined()
28
+ return directory.appendingPathComponent("craft-\(kind)-\(acknowledged ? "ack" : "completed")-\(digest).json")
29
+ }
30
+ static func startId(_ options: [String: Any], directory: URL, kind: String, currentId: String?) throws -> String {
31
+ let id: String
32
+ if options.keys.contains("recordingId") {
33
+ guard let requested = options["recordingId"] as? String,
34
+ !requested.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
35
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "recordingId must be a nonempty recording ID")
36
+ }
37
+ id = requested
38
+ } else { id = UUID().uuidString }
39
+ let acknowledged = try isAcknowledged(directory, kind, id)
40
+ if id == currentId || acknowledged || FileManager.default.fileExists(atPath: url(directory, kind, id).path) {
41
+ throw CraftNativeActionError(code: "RECORDING_CONFLICT", message: "Recording IDs cannot be reused")
42
+ }
43
+ return id
44
+ }
45
+ static func isAcknowledged(_ directory: URL, _ kind: String, _ id: String) throws -> Bool {
46
+ let marker = url(directory, kind, id, acknowledged: true)
47
+ guard FileManager.default.fileExists(atPath: marker.path) else { return false }
48
+ guard try Data(contentsOf: marker) == Data(id.utf8) else {
49
+ throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Invalid recording acknowledgement")
50
+ }
51
+ return true
52
+ }
53
+ static func read(_ directory: URL, _ kind: String, _ id: String) throws -> [String: Any]? {
54
+ if try isAcknowledged(directory, kind, id) {
55
+ try acknowledge(directory, kind, id)
56
+ throw CraftNativeActionError(code: "RECORDING_NOT_FOUND", message: "This recording has been acknowledged")
57
+ }
58
+ let source = url(directory, kind, id)
59
+ guard FileManager.default.fileExists(atPath: source.path) else { return nil }
60
+ let data = try Data(contentsOf: source)
61
+ guard let value = try JSONSerialization.jsonObject(with: data) as? [String: Any],
62
+ value["id"] as? String == id, value["active"] as? Bool == false,
63
+ value[kind == "location" ? "locations" : "samples"] is [[String: Any]] else {
64
+ throw CraftNativeActionError(code: "RECORDING_STORAGE_ERROR", message: "Invalid completed recording archive")
65
+ }
66
+ return value
67
+ }
68
+ static func save(_ directory: URL, _ kind: String, _ state: [String: Any], samples: [[String: Any]], writer: Writer = writeAtomically) throws {
69
+ guard let id = state["id"] as? String else { return }
70
+ if try isAcknowledged(directory, kind, id) { try acknowledge(directory, kind, id); return }
71
+ if try read(directory, kind, id) != nil { return }
72
+ var completed = state
73
+ completed["active"] = false
74
+ completed["paused"] = false
75
+ completed["sampleCount"] = samples.count
76
+ completed[kind == "location" ? "locations" : "samples"] = samples
77
+ try writer(JSONSerialization.data(withJSONObject: completed), url(directory, kind, id))
78
+ }
79
+ static func acknowledge(_ directory: URL, _ kind: String, _ id: String) throws {
80
+ let marker = url(directory, kind, id, acknowledged: true)
81
+ if try !isAcknowledged(directory, kind, id) { try writeAtomically(Data(id.utf8), marker) }
82
+ let archive = url(directory, kind, id)
83
+ if FileManager.default.fileExists(atPath: archive.path) { try FileManager.default.removeItem(at: archive) }
84
+ }
85
+ }
@@ -21,6 +21,7 @@ import Darwin
21
21
  /// tap <text> first hittable element whose label contains text
22
22
  /// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
23
23
  /// tapid <identifier>
24
+ /// longpressid <identifier> holds a hittable native element to open its edit menu
24
25
  /// touches <JSON> timed normalized touch paths in one native dispatch
25
26
  /// tapxy <x> <y> normalised 0…1 of the screen
26
27
  /// tapexact <label> a button whose label is exactly this
@@ -29,11 +30,14 @@ import Darwin
29
30
  /// return the keyboard's return key (Go on a sign-in form)
30
31
  /// field <n> <text> tap the n-th text field (0-based, secure fields after plain) and type
31
32
  /// typeid <identifier> <text> tap the element with this identifier and type
33
+ /// sliderid <identifier> <0…1> adjusts a hittable native slider
34
+ /// expectapp <bundle-id> [| secs] asserts a native app handoff reached the foreground
32
35
  /// expect <text> [| secs] waits for an element containing text; logs PASS/FAIL
33
36
  /// expectexact <label> [| secs] a button or text whose label is exactly this
34
37
  /// absent <text> logs PASS when no element contains text
35
38
  /// expectid <id> [| secs] / absentid <id> the same by accessibility identifier (testID)
36
39
  /// labelid <id> <text> that element's label is exactly text (a count, a status)
40
+ /// valueid <id> <text> that element's actual string value is exactly text
37
41
  /// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
38
42
  /// selected <tab> the tab bar's selected button is this one
39
43
  /// tab <name> tap a tab bar button
@@ -53,6 +57,7 @@ final class DriverTests: XCTestCase {
53
57
  let control = ProcessInfo.processInfo.environment["CRAFT_TEST_CONTROL"] ?? {{CONTROL_URL}}
54
58
  let app = XCUIApplication(bundleIdentifier: {{BUNDLE_ID}})
55
59
  let tabs: [String] = {{TABS}}
60
+ let tabIdentifiers: [String: String] = {{TAB_IDENTIFIERS}}
56
61
  private var bundledJourney = false
57
62
 
58
63
  func get(_ path: String, timeout: Double = 15) -> Data? {
@@ -140,9 +145,15 @@ final class DriverTests: XCTestCase {
140
145
 
141
146
  /// The native tab bar's button: Craft's floating bar is not a UITabBar, and
142
147
  /// XCUITest calls its buttons not hittable, so it is the button with this
143
- /// label in the bottom fifth of the window.
148
+ /// label in the bottom fifth of the window. A configured stable identifier
149
+ /// keeps dynamic captions from changing the tab's native identity.
144
150
  func tabButton(_ name: String) -> XCUIElement? {
145
151
  let height = app.windows.firstMatch.frame.height
152
+ if let identifier = tabIdentifiers[name] {
153
+ let candidate = app.buttons.matching(NSPredicate(format: "identifier == %@", identifier)).firstMatch
154
+ if candidate.exists && candidate.frame.minY > height * 0.8 && candidate.frame.maxY <= height + 1 && candidate.frame.width > 0 { return candidate }
155
+ return nil
156
+ }
146
157
  let matches = app.buttons.matching(NSPredicate(format: "label == %@", name))
147
158
  for index in 0..<min(matches.count, 10) {
148
159
  let candidate = matches.element(boundBy: index)
@@ -290,6 +301,14 @@ final class DriverTests: XCTestCase {
290
301
  let target = identified(arg)
291
302
  if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
292
303
  else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
304
+ case "longpressid":
305
+ let target = identified(arg)
306
+ guard target.waitForExistence(timeout: 10), target.isHittable else {
307
+ check(false, "longpressid \(arg) is hittable")
308
+ return
309
+ }
310
+ target.press(forDuration: 1.2)
311
+ note("longpressid \(arg): ok")
293
312
  case "touches":
294
313
  do {
295
314
  guard let data = arg.data(using: .utf8),
@@ -360,6 +379,23 @@ final class DriverTests: XCTestCase {
360
379
  app.typeText(idParts.count > 1 ? idParts[1] : "")
361
380
  note("typeid \(idParts[0]): ok")
362
381
  } else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
382
+ case "sliderid":
383
+ let parts = arg.split(separator: " ", maxSplits: 1).map(String.init)
384
+ guard parts.count == 2, let position = Double(parts[1]), position.isFinite, (0...1).contains(position) else {
385
+ check(false, "sliderid requires an identifier and normalized position")
386
+ return
387
+ }
388
+ let slider = app.sliders.matching(NSPredicate(format: "identifier == %@", parts[0])).firstMatch
389
+ guard slider.waitForExistence(timeout: 10), slider.isHittable else {
390
+ check(false, "sliderid \(parts[0]) is hittable")
391
+ return
392
+ }
393
+ slider.adjust(toNormalizedSliderPosition: CGFloat(position))
394
+ note("sliderid \(parts[0]): \(position)")
395
+ case "expectapp":
396
+ let (bundle, seconds) = split(arg)
397
+ let target = XCUIApplication(bundleIdentifier: bundle)
398
+ check(target.wait(for: .runningForeground, timeout: seconds), "expectapp \(bundle)")
363
399
  case "field":
364
400
  let fieldParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
365
401
  let index = Int(fieldParts[0]) ?? 0
@@ -385,6 +421,14 @@ final class DriverTests: XCTestCase {
385
421
  let want = idParts.count > 1 ? idParts[1] : ""
386
422
  let seen = target.waitForExistence(timeout: 5) ? target.label : "(missing)"
387
423
  check(seen == want, "labelid \(idParts[0]) \(want) (saw \(seen))")
424
+ case "valueid":
425
+ let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
426
+ let target = identified(idParts[0])
427
+ let want = idParts.count > 1 ? idParts[1] : ""
428
+ let matched = poll(5) {
429
+ target.exists && (target.value as? String) == want ? target : nil
430
+ }
431
+ check(matched != nil, "valueid \(idParts[0]) \(want)")
388
432
  case "absentid":
389
433
  check(!identified(arg).exists, "absentid \(arg)")
390
434
  case "absent":