@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.
- package/dist/app-shortcuts.d.ts +11 -0
- package/dist/cli.js +58 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +65 -1
- package/dist/testing.d.ts +2 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +392 -88
- package/templates/CraftHeartRate.swift +141 -44
- package/templates/CraftNativeActions.swift +46 -48
- package/templates/CraftNativeScreen.swift +177 -10
- package/templates/CraftPersistentStorage.swift +99 -0
- package/templates/CraftRecordingArchive.swift +85 -0
- package/templates/testing/DriverTests.swift.template +8 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
1326
|
-
??
|
|
1472
|
+
let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
|
|
1473
|
+
?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
|
|
1327
1474
|
view.annotation = annotation
|
|
1328
|
-
view
|
|
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)
|