@craft-native/ios 0.0.236 → 0.0.238

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.
@@ -1161,8 +1161,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1161
1161
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
1162
1162
  private var initialCameraKey: String?
1163
1163
  private var pendingFitKey: String?
1164
+ private var pendingRegion: MKCoordinateRegion?
1164
1165
  private(set) var markerHandlers: [String: String] = [:]
1165
1166
  var onMarkerTap: ((String) -> Void)?
1167
+ var onMapPress: ((CLLocationCoordinate2D) -> Void)?
1168
+ var onRegionChangeComplete: ((MKCoordinateRegion, Double, Double) -> Void)?
1169
+ private var userChangingCamera = false
1170
+ private var authoredPitch: Double?
1171
+ private var authoredHeading: Double?
1172
+ private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1173
+ private var markerBearings: [String: Double] = [:]
1174
+ private var tileOverlay: MKTileOverlay?
1175
+ private var tileTemplate: String?
1176
+ private lazy var tileAttribution: UILabel = {
1177
+ let label = UILabel()
1178
+ label.font = .systemFont(ofSize: 9)
1179
+ label.textColor = .darkGray
1180
+ label.backgroundColor = UIColor.white.withAlphaComponent(0.85)
1181
+ label.translatesAutoresizingMaskIntoConstraints = false
1182
+ label.numberOfLines = 2
1183
+ 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)])
1185
+ return label
1186
+ }()
1166
1187
 
1167
1188
  override init(frame: CGRect) {
1168
1189
  super.init(frame: frame)
@@ -1170,6 +1191,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1170
1191
  showsCompass = false
1171
1192
  showsScale = false
1172
1193
  isRotateEnabled = false
1194
+ let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
1195
+ tap.cancelsTouchesInView = false
1196
+ addGestureRecognizer(tap)
1173
1197
  }
1174
1198
 
1175
1199
  required init?(coder: NSCoder) {
@@ -1183,6 +1207,18 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1183
1207
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
1184
1208
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1185
1209
  mapType = Self.mapType(props["mapType"] as? String)
1210
+ configureTiles(props["tileOverlay"] as? [String: Any])
1211
+ showsCompass = props["showsCompass"] as? Bool ?? false
1212
+ showsScale = props["showsScale"] as? Bool ?? false
1213
+ authoredPitch = Self.numeric(props["cameraPitch"])
1214
+ authoredHeading = Self.numeric(props["cameraHeading"])
1215
+ let padding = props["edgePadding"] as? [String: Any]
1216
+ func inset(_ name: String) -> CGFloat {
1217
+ guard let value = Self.numeric(padding?[name]), value.isFinite else { return 24 }
1218
+ return CGFloat(min(10_000, max(0, value)))
1219
+ }
1220
+ edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
1221
+ var nextBearings: [String: Double] = [:]
1186
1222
  // Showing the user dot must not trigger a permission prompt from a
1187
1223
  // renderer pass. The app's existing location capability owns that
1188
1224
  // prompt; a map only opts in after authorization is already granted.
@@ -1203,6 +1239,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1203
1239
  if type == "mapmarker" || type == "marker" {
1204
1240
  guard let point = points.first else { continue }
1205
1241
  nextMarkers[id] = (point, childProps["title"] as? String)
1242
+ if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
1243
+ nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
1244
+ }
1206
1245
  if let handler = nonEmptyHandler(childEvents["onPress"])
1207
1246
  ?? nonEmptyHandler(childEvents["onMarkerPress"]) {
1208
1247
  nextHandlers[id] = handler
@@ -1216,12 +1255,14 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1216
1255
  }
1217
1256
  }
1218
1257
 
1258
+ markerBearings = nextBearings
1219
1259
  let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
1220
1260
  removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
1221
1261
  for (id, value) in nextMarkers {
1222
1262
  if let annotation = annotationsByID[id] {
1223
1263
  annotation.coordinate = value.coordinate
1224
1264
  annotation.title = value.title
1265
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1225
1266
  } else {
1226
1267
  let annotation = MKPointAnnotation()
1227
1268
  annotation.coordinate = value.coordinate
@@ -1266,17 +1307,24 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1266
1307
  // `region` is controlled: an authored change is allowed to move
1267
1308
  // the camera, even when the map itself was already mounted.
1268
1309
  pendingFitKey = nil
1269
- setRegion(region, animated: false)
1310
+ pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : region
1311
+ if abs(self.region.center.latitude - region.center.latitude) > 0.00000001
1312
+ || abs(self.region.center.longitude - region.center.longitude) > 0.00000001
1313
+ || abs(self.region.span.latitudeDelta - region.span.latitudeDelta) > 0.00000001
1314
+ || abs(self.region.span.longitudeDelta - region.span.longitudeDelta) > 0.00000001 {
1315
+ setRegion(region, animated: false)
1316
+ }
1270
1317
  initialCameraKey = nil
1271
1318
  } else if let initial = Self.region(props["initialRegion"]) {
1272
1319
  pendingFitKey = nil
1273
1320
  let key = "region:\(Self.regionKey(initial))"
1274
1321
  if initialCameraKey != key {
1322
+ pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : initial
1275
1323
  setRegion(initial, animated: false)
1276
1324
  initialCameraKey = key
1277
1325
  }
1278
1326
  } else if props["fitToBounds"] as? Bool == true {
1279
- let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }))"
1327
+ let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates })):\(edgePadding)"
1280
1328
  if initialCameraKey != key {
1281
1329
  // A native screen can reconcile before UIKit assigns the map
1282
1330
  // its final safe-area-constrained bounds. Keep the fit
@@ -1292,10 +1340,96 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1292
1340
  initialCameraKey = nil
1293
1341
  pendingFitKey = nil
1294
1342
  }
1343
+ applyCameraOrientation()
1344
+ }
1345
+
1346
+ private func configureTiles(_ options: [String: Any]?) {
1347
+ let template = options?["urlTemplate"] as? String
1348
+ let credit = options?["attribution"] as? String ?? ""
1349
+ let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
1350
+ let next = valid && !credit.isEmpty ? template : nil
1351
+ if tileTemplate != next {
1352
+ if let overlay = tileOverlay { removeOverlay(overlay) }
1353
+ tileOverlay = nil
1354
+ tileTemplate = next
1355
+ if let next {
1356
+ let overlay = MKTileOverlay(urlTemplate: next)
1357
+ overlay.canReplaceMapContent = true
1358
+ tileOverlay = overlay
1359
+ insertOverlay(overlay, at: 0, level: .aboveRoads)
1360
+ }
1361
+ }
1362
+ tileAttribution.text = next == nil ? nil : credit
1363
+ tileAttribution.isHidden = next == nil
1364
+ }
1365
+
1366
+ private func applyCameraOrientation() {
1367
+ // Region-to-altitude conversion needs a measured viewport. Applying
1368
+ // pitch to the zero-altitude camera before layout clamps it to zero.
1369
+ guard bounds.width > 0, bounds.height > 0 else { return }
1370
+ let next = camera.copy() as! MKMapCamera
1371
+ var changed = false
1372
+ if let pitch = authoredPitch, pitch.isFinite {
1373
+ let value = CGFloat(min(60, max(0, pitch)))
1374
+ if abs(next.pitch - value) > 0.01 { next.pitch = value; changed = true }
1375
+ }
1376
+ if let heading = authoredHeading, heading.isFinite {
1377
+ let value = heading.truncatingRemainder(dividingBy: 360)
1378
+ if abs(next.heading - value) > 0.01 { next.heading = value; changed = true }
1379
+ }
1380
+ if changed { setCamera(next, animated: false) }
1381
+ }
1382
+
1383
+ @objc private func pressedMap(_ gesture: UITapGestureRecognizer) {
1384
+ guard gesture.state == .ended, onMapPress != nil else { return }
1385
+ let point = gesture.location(in: self)
1386
+ var touched = hitTest(point, with: nil)
1387
+ while let view = touched, view !== self {
1388
+ if view is MKAnnotationView { return }
1389
+ touched = view.superview
1390
+ }
1391
+ onMapPress?(convert(point, toCoordinateFrom: self))
1392
+ }
1393
+
1394
+ func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
1395
+ func interacting(_ view: UIView) -> Bool {
1396
+ if view.gestureRecognizers?.contains(where: { [.began, .changed, .ended].contains($0.state) }) == true { return true }
1397
+ return view.subviews.contains(where: interacting)
1398
+ }
1399
+ userChangingCamera = userChangingCamera || interacting(self)
1400
+ }
1401
+
1402
+ func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
1403
+ for (id, annotation) in annotationsByID {
1404
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1405
+ }
1406
+ if userChangingCamera {
1407
+ userChangingCamera = false
1408
+ onRegionChangeComplete?(region, camera.heading, Double(camera.pitch))
1409
+ }
1410
+ }
1411
+
1412
+ private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1413
+ if let bearing = markerBearings[id] {
1414
+ let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
1415
+ .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1416
+ view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
1417
+ view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1418
+ } else {
1419
+ view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1420
+ .withTintColor(.systemRed, renderingMode: .alwaysOriginal)
1421
+ view.transform = .identity
1422
+ }
1295
1423
  }
1296
1424
 
1297
1425
  override func layoutSubviews() {
1298
1426
  super.layoutSubviews()
1427
+ if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
1428
+ if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
1429
+ pendingRegion = nil
1430
+ setRegion(region, animated: false)
1431
+ applyCameraOrientation()
1432
+ }
1299
1433
  guard let key = pendingFitKey else { return }
1300
1434
  pendingFitKey = nil
1301
1435
  guard fitToOverlays() else {
@@ -1303,6 +1437,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1303
1437
  return
1304
1438
  }
1305
1439
  initialCameraKey = key
1440
+ applyCameraOrientation()
1306
1441
  }
1307
1442
 
1308
1443
  @discardableResult
@@ -1315,17 +1450,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1315
1450
  rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
1316
1451
  }
1317
1452
  guard !rect.isNull else { return false }
1318
- let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1453
+ var inset = edgePadding
1454
+ let horizontal = inset.left + inset.right
1455
+ let vertical = inset.top + inset.bottom
1456
+ let availableWidth = max(0, bounds.width - 40)
1457
+ let availableHeight = max(0, bounds.height - 40)
1458
+ if horizontal > availableWidth && horizontal > 0 {
1459
+ inset.left *= availableWidth / horizontal
1460
+ inset.right *= availableWidth / horizontal
1461
+ }
1462
+ if vertical > availableHeight && vertical > 0 {
1463
+ inset.top *= availableHeight / vertical
1464
+ inset.bottom *= availableHeight / vertical
1465
+ }
1319
1466
  setVisibleMapRect(rect, edgePadding: inset, animated: false)
1320
1467
  return true
1321
1468
  }
1322
1469
 
1323
1470
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
1324
1471
  guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
1325
- let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
1326
- ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1472
+ let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
1473
+ ?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1327
1474
  view.annotation = annotation
1328
- view.markerTintColor = .systemRed
1475
+ configureMarkerView(view, id: id)
1329
1476
  view.accessibilityIdentifier = id
1330
1477
  return view
1331
1478
  }
@@ -1337,6 +1484,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1337
1484
  }
1338
1485
 
1339
1486
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
1487
+ if let tiles = overlay as? MKTileOverlay { return MKTileOverlayRenderer(tileOverlay: tiles) }
1340
1488
  let renderer = MKPolylineRenderer(overlay: overlay)
1341
1489
  renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
1342
1490
  renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
@@ -4649,7 +4797,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4649
4797
  CraftNativeConsole.write(level, category: category, message: message)
4650
4798
  }
4651
4799
  let storageGet: @convention(block) (String) -> String? = { key in
4652
- CraftNativeActions.storageJSON(forKey: key)
4800
+ do { return try CraftNativeActions.storageJSON(forKey: key) }
4801
+ catch {
4802
+ if let context = JSContext.current() {
4803
+ context.exception = JSValue(newErrorFromMessage: "STORAGE_ERROR: Unable to read stored value", in: context)
4804
+ }
4805
+ return nil
4806
+ }
4653
4807
  }
4654
4808
  let storageSet: @convention(block) (String, JSValue) -> Bool = { key, value in
4655
4809
  CraftNativeActions.setStorageJSON(value.isNull || value.isUndefined ? nil : value.toString(), forKey: key)
@@ -6298,6 +6452,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6298
6452
  case "Map":
6299
6453
  let map = result as! CraftNativeMapView
6300
6454
  let mapMarkerHandler = nonEmptyHandler(events["onMarkerPress"])
6455
+ map.onMapPress = { [weak self] coordinate in
6456
+ guard let self, let handler = self.nonEmptyHandler(events["onPress"]) else { return }
6457
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["latitude": coordinate.latitude, "longitude": coordinate.longitude]])
6458
+ }
6459
+ map.onRegionChangeComplete = { [weak self] region, heading, pitch in
6460
+ guard let self, let handler = self.nonEmptyHandler(events["onRegionChangeComplete"]) else { return }
6461
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [
6462
+ "center": ["latitude": region.center.latitude, "longitude": region.center.longitude],
6463
+ "span": ["latitudeDelta": region.span.latitudeDelta, "longitudeDelta": region.span.longitudeDelta],
6464
+ "heading": heading, "pitch": pitch,
6465
+ ]])
6466
+ }
6301
6467
  map.configure(props: props, children: children) { [weak self, weak map] markerID in
6302
6468
  guard let self, let map,
6303
6469
  let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
@@ -7337,13 +7503,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7337
7503
  .font: font,
7338
7504
  ]
7339
7505
  if let spacing = number(style["letterSpacing"]) { attributes[.kern] = spacing }
7506
+ // UIKit otherwise resets the field's alignment to natural when these attributes are applied.
7507
+ let paragraph = NSMutableParagraphStyle()
7508
+ paragraph.alignment = alignment
7340
7509
  if let lineHeight = number(style["lineHeight"]) {
7341
- let paragraph = NSMutableParagraphStyle()
7342
7510
  paragraph.minimumLineHeight = lineHeight
7343
7511
  paragraph.maximumLineHeight = lineHeight
7344
- paragraph.alignment = alignment
7345
- attributes[.paragraphStyle] = paragraph
7346
7512
  }
7513
+ attributes[.paragraphStyle] = paragraph
7347
7514
  addTextDecorationAttributes(style, to: &attributes)
7348
7515
  if let shadow = textShadow(style) { attributes[.shadow] = shadow }
7349
7516
  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
+ }
@@ -53,6 +53,7 @@ final class DriverTests: XCTestCase {
53
53
  let control = ProcessInfo.processInfo.environment["CRAFT_TEST_CONTROL"] ?? {{CONTROL_URL}}
54
54
  let app = XCUIApplication(bundleIdentifier: {{BUNDLE_ID}})
55
55
  let tabs: [String] = {{TABS}}
56
+ let tabIdentifiers: [String: String] = {{TAB_IDENTIFIERS}}
56
57
  private var bundledJourney = false
57
58
 
58
59
  func get(_ path: String, timeout: Double = 15) -> Data? {
@@ -140,9 +141,15 @@ final class DriverTests: XCTestCase {
140
141
 
141
142
  /// The native tab bar's button: Craft's floating bar is not a UITabBar, and
142
143
  /// XCUITest calls its buttons not hittable, so it is the button with this
143
- /// label in the bottom fifth of the window.
144
+ /// label in the bottom fifth of the window. A configured stable identifier
145
+ /// keeps dynamic captions from changing the tab's native identity.
144
146
  func tabButton(_ name: String) -> XCUIElement? {
145
147
  let height = app.windows.firstMatch.frame.height
148
+ if let identifier = tabIdentifiers[name] {
149
+ let candidate = app.buttons.matching(NSPredicate(format: "identifier == %@", identifier)).firstMatch
150
+ if candidate.exists && candidate.frame.minY > height * 0.8 && candidate.frame.maxY <= height + 1 && candidate.frame.width > 0 { return candidate }
151
+ return nil
152
+ }
146
153
  let matches = app.buttons.matching(NSPredicate(format: "label == %@", name))
147
154
  for index in 0..<min(matches.count, 10) {
148
155
  let candidate = matches.element(boundBy: index)