@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.
@@ -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)