@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.
- package/dist/cli.js +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +17 -1
- package/dist/testing.d.ts +16 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +418 -93
- package/templates/CraftHeartRate.swift +141 -44
- package/templates/CraftNativeActions.swift +46 -48
- package/templates/CraftNativeScreen.swift +82 -17
- package/templates/CraftPersistentStorage.swift +99 -0
- package/templates/CraftRecordingArchive.swift +85 -0
- package/templates/testing/DriverTests.swift.template +45 -1
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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:
|
|
1438
|
+
let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
|
|
1415
1439
|
.applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
|
|
1416
|
-
|
|
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(.
|
|
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 {
|
|
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
|
-
|
|
2686
|
-
|
|
2687
|
-
let
|
|
2688
|
-
|
|
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":
|