@craft-native/ios 0.0.237 → 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/cli.js +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +9 -1
- package/dist/testing.d.ts +2 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +376 -88
- package/templates/CraftHeartRate.swift +141 -44
- package/templates/CraftNativeActions.swift +46 -48
- package/templates/CraftNativeScreen.swift +11 -4
- package/templates/CraftPersistentStorage.swift +99 -0
- package/templates/CraftRecordingArchive.swift +85 -0
- package/templates/testing/DriverTests.swift.template +8 -1
|
@@ -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)
|