craft-native 0.0.435 → 0.0.442

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.
Files changed (39) hide show
  1. package/LICENSE.md +30 -0
  2. package/README.md +35 -0
  3. package/dist/android/src/index.d.ts +8 -0
  4. package/dist/android/src/index.js +17 -2
  5. package/dist/android/templates/CraftHealthServices.kt.template +2 -0
  6. package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
  7. package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
  8. package/dist/android/templates/CraftNativeServices.kt.template +6 -1
  9. package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
  10. package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
  11. package/dist/android/templates/LocationRecordingService.kt.template +77 -9
  12. package/dist/android/templates/MainActivityNative.kt.template +129 -1
  13. package/dist/cli.js +289 -108
  14. package/dist/components/index.d.ts +25 -0
  15. package/dist/index.cjs +207 -27
  16. package/dist/index.d.cts +2 -2
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +321 -140
  19. package/dist/installer-background.d.ts +2 -0
  20. package/dist/installer-layout.d.ts +1 -0
  21. package/dist/ios/src/index.d.ts +10 -0
  22. package/dist/ios/src/index.js +28 -2
  23. package/dist/ios/templates/CraftApp.swift +14 -0
  24. package/dist/ios/templates/CraftHealth.swift +2 -1
  25. package/dist/ios/templates/CraftHealthBackground.swift +308 -0
  26. package/dist/ios/templates/CraftNativeActions.swift +153 -0
  27. package/dist/ios/templates/CraftNativeMutation.swift +18 -3
  28. package/dist/ios/templates/CraftNativePedometer.swift +181 -0
  29. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +24 -4
  31. package/dist/macos/src/index.js +1 -1
  32. package/dist/macos/src/toolchain.d.ts +1 -1
  33. package/dist/macos/templates/CraftMacApplication.swift +124 -39
  34. package/dist/macos/templates/CraftMacRenderer.swift +169 -37
  35. package/dist/macos/templates/CraftMacRuntime.swift +298 -77
  36. package/dist/macos/templates/CraftNativeMutation.swift +18 -3
  37. package/dist/package.d.ts +40 -0
  38. package/dist/types.d.ts +26 -0
  39. package/package.json +1 -1
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
18
18
  struct CraftNativeMutationResult {
19
19
  let batchId: String
20
20
  let revision: Int
21
- let document: [String: Any]?
22
21
  let updatedNodeIds: [String]
23
22
  let affectedNodeIds: [String]
24
23
  let requiresFullRender: Bool
24
+ private let materialize: () -> [String: Any]?
25
+ /// The committed tree, built when asked for: a host that redraws only the
26
+ /// nodes a batch touched never needs the whole tree.
27
+ var document: [String: Any]? { materialize() }
28
+
29
+ init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
30
+ self.batchId = batchId
31
+ self.revision = revision
32
+ self.materialize = document
33
+ self.updatedNodeIds = updatedNodeIds
34
+ self.affectedNodeIds = affectedNodeIds
35
+ self.requiresFullRender = requiresFullRender
36
+ }
25
37
  }
26
38
 
27
39
  /// Validates and applies versioned native-tree mutations without touching UIKit.
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
91
103
  var updatedNodeIds: [String] = []
92
104
  var affectedNodeIds: [String] = []
93
105
  var requiresFullRender = false
106
+ // Only an operation that adds, moves or removes nodes can leave one unreachable.
107
+ var reshaped = false
94
108
  for (index, value) in operations.enumerated() {
95
109
  guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
96
110
  throw CraftNativeMutationFailure(
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
99
113
  operationIndex: index
100
114
  )
101
115
  }
116
+ if name != "updateNode" { reshaped = true }
102
117
  do {
103
118
  switch name {
104
119
  case "createNode":
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
147
162
  guard let root = candidateNodes[candidateRoot], root.parent == nil else {
148
163
  throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
149
164
  }
150
- guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
165
+ guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
151
166
  throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
152
167
  }
153
168
  }
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
158
173
  return CraftNativeMutationResult(
159
174
  batchId: batchId,
160
175
  revision: nextRevision,
161
- document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
176
+ document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
162
177
  updatedNodeIds: updatedNodeIds,
163
178
  affectedNodeIds: affectedNodeIds,
164
179
  requiresFullRender: requiresFullRender
@@ -0,0 +1,181 @@
1
+ import Foundation
2
+ #if CRAFT_PERMISSION_MOTION
3
+ import CoreMotion
4
+ #endif
5
+
6
+ struct CraftPedometerSample {
7
+ let startDate: Double
8
+ let endDate: Double
9
+ let steps: Int
10
+ var distanceMeters: Double?
11
+ var cadenceStepsPerSecond: Double?
12
+ var paceSecondsPerMeter: Double?
13
+ var averageActivePaceSecondsPerMeter: Double?
14
+ var floorsAscended: Int?
15
+ var floorsDescended: Int?
16
+
17
+ var payload: [String: Any] {
18
+ var value: [String: Any] = ["state": "sample", "kind": "interval", "startDate": startDate, "endDate": endDate, "steps": steps]
19
+ if let distanceMeters { value["distanceMeters"] = distanceMeters }
20
+ if let cadenceStepsPerSecond { value["cadenceStepsPerSecond"] = cadenceStepsPerSecond }
21
+ if let paceSecondsPerMeter { value["paceSecondsPerMeter"] = paceSecondsPerMeter }
22
+ if let averageActivePaceSecondsPerMeter { value["averageActivePaceSecondsPerMeter"] = averageActivePaceSecondsPerMeter }
23
+ if let floorsAscended { value["floorsAscended"] = floorsAscended }
24
+ if let floorsDescended { value["floorsDescended"] = floorsDescended }
25
+ return value
26
+ }
27
+ }
28
+
29
+ protocol CraftPedometerSource: AnyObject {
30
+ var availability: [String: Bool] { get }
31
+ var permission: String { get }
32
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void)
33
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void)
34
+ func stop()
35
+ }
36
+
37
+ #if CRAFT_PERMISSION_MOTION
38
+ private final class CraftCoreMotionPedometer: CraftPedometerSource {
39
+ private let pedometer = CMPedometer()
40
+ var availability: [String: Bool] {
41
+ ["steps": CMPedometer.isStepCountingAvailable(), "distance": CMPedometer.isDistanceAvailable(),
42
+ "floors": CMPedometer.isFloorCountingAvailable(), "cadence": CMPedometer.isCadenceAvailable(),
43
+ "pace": CMPedometer.isPaceAvailable(), "history": CMPedometer.isStepCountingAvailable(), "background": false]
44
+ }
45
+ var permission: String {
46
+ switch CMPedometer.authorizationStatus() {
47
+ case .authorized: return "granted"
48
+ case .denied: return "denied"
49
+ case .restricted: return "restricted"
50
+ case .notDetermined: return "notDetermined"
51
+ @unknown default: return "unavailable"
52
+ }
53
+ }
54
+ private func handler(_ receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) -> CMPedometerHandler {
55
+ { data, error in
56
+ let result: Result<CraftPedometerSample, Error>
57
+ if let error { result = .failure(error) }
58
+ else if let data {
59
+ result = .success(CraftPedometerSample(startDate: data.startDate.timeIntervalSince1970 * 1000,
60
+ endDate: data.endDate.timeIntervalSince1970 * 1000, steps: data.numberOfSteps.intValue,
61
+ distanceMeters: data.distance?.doubleValue, cadenceStepsPerSecond: data.currentCadence?.doubleValue,
62
+ paceSecondsPerMeter: data.currentPace?.doubleValue, averageActivePaceSecondsPerMeter: data.averageActivePace?.doubleValue,
63
+ floorsAscended: data.floorsAscended?.intValue, floorsDescended: data.floorsDescended?.intValue))
64
+ } else { result = .failure(NSError(domain: "CraftPedometer", code: 1, userInfo: [NSLocalizedDescriptionKey: "Pedometer returned no data"])) }
65
+ DispatchQueue.main.async { receive(result) }
66
+ }
67
+ }
68
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {
69
+ pedometer.startUpdates(from: from, withHandler: handler(receive))
70
+ }
71
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {
72
+ pedometer.queryPedometerData(from: from, to: to, withHandler: handler(receive))
73
+ }
74
+ func stop() { pedometer.stopUpdates() }
75
+ }
76
+ #endif
77
+
78
+ private final class CraftUnavailablePedometer: CraftPedometerSource {
79
+ var availability: [String: Bool] { ["steps": false, "distance": false, "floors": false, "cadence": false, "pace": false, "history": false, "background": false] }
80
+ var permission: String { "unavailable" }
81
+ func start(from: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {}
82
+ func query(from: Date, to: Date, receive: @escaping (Result<CraftPedometerSample, Error>) -> Void) {}
83
+ func stop() {}
84
+ }
85
+
86
+ /// Interval counts are never relabelled as Android's counter since boot.
87
+ final class CraftNativePedometer {
88
+ typealias Completion = (Result<Any, CraftNativeActionError>) -> Void
89
+ private let makeSource: () -> CraftPedometerSource
90
+ private let now: () -> Date
91
+ private var live: CraftPedometerSource?
92
+ private var pending = [String: CraftPedometerSource]()
93
+ private var closed = false
94
+ var emit: ([String: Any]) -> Void = { _ in }
95
+
96
+ init(makeSource: (() -> CraftPedometerSource)? = nil, now: @escaping () -> Date = Date.init) {
97
+ self.now = now
98
+ self.makeSource = makeSource ?? {
99
+ #if CRAFT_PERMISSION_MOTION
100
+ return CraftCoreMotionPedometer()
101
+ #else
102
+ return CraftUnavailablePedometer()
103
+ #endif
104
+ }
105
+ }
106
+
107
+ func perform(method: String, args: [Any], token: String, completion: @escaping Completion) {
108
+ guard !closed else { return completion(failure("HOST_CLOSED", "Pedometer host is closed")) }
109
+ if method == "stop" { completion(.success(stop(reason: "requested"))); return }
110
+ let source = makeSource()
111
+ if method == "getAvailability" { completion(.success(source.availability)); return }
112
+ if method == "getPermissionStatus" { completion(.success(source.permission)); return }
113
+ guard ["start", "query", "requestPermission"].contains(method) else { return completion(failure("UNKNOWN_ACTION", "Unsupported Pedometer method")) }
114
+ guard source.availability["steps"] == true else { return completion(failure("UNAVAILABLE", "Step counting is not present")) }
115
+ if method == "requestPermission", ["granted", "denied", "restricted"].contains(source.permission) {
116
+ completion(.success(source.permission)); return
117
+ }
118
+ guard !["denied", "restricted"].contains(source.permission) else { return completion(failure("PERMISSION_DENIED", "Motion access was denied or restricted")) }
119
+ guard args.isEmpty || args.first is [String: Any] else { return completion(failure("INVALID_ARGUMENT", "Pedometer options must be an object")) }
120
+ let options = args.first as? [String: Any] ?? [:]
121
+ if let background = options["background"] {
122
+ guard let flag = background as? NSNumber, CFGetTypeID(flag) == CFBooleanGetTypeID() else { return completion(failure("INVALID_ARGUMENT", "background must be boolean")) }
123
+ if flag.boolValue { return completion(failure("UNSUPPORTED", "Background pedometer streaming is not supported")) }
124
+ }
125
+ let current = now().timeIntervalSince1970 * 1000
126
+ let first: Double, last: Double
127
+ if method == "requestPermission" { first = current - 1000; last = current }
128
+ else {
129
+ guard let start = date(options["startDate"] ?? (method == "start" ? current : NSNull())),
130
+ let end = date(options["endDate"] ?? current), start >= current - 7 * 86400 * 1000,
131
+ start <= current, end <= current, (method == "start" ? start <= end : start < end) else {
132
+ return completion(failure("INVALID_ARGUMENT", "Dates must describe an interval within the last seven days"))
133
+ }
134
+ first = start; last = end
135
+ }
136
+ if method == "start" {
137
+ let previous = live
138
+ live = source
139
+ source.start(from: Date(timeIntervalSince1970: first / 1000)) { [weak self, weak source] result in
140
+ guard let self, let source, !self.closed, self.live === source else { return }
141
+ switch result {
142
+ case .success(let sample): self.emit(sample.payload)
143
+ case .failure: self.stop(reason: ["denied", "restricted"].contains(source.permission) ? "permissionDenied" : "sensorError")
144
+ }
145
+ }
146
+ previous?.stop()
147
+ completion(.success(true))
148
+ } else {
149
+ pending[token] = source
150
+ source.query(from: Date(timeIntervalSince1970: first / 1000), to: Date(timeIntervalSince1970: last / 1000)) { [weak self, weak source] result in
151
+ guard let self, let source, !self.closed, self.pending[token] === source else { return }
152
+ self.pending.removeValue(forKey: token)
153
+ if method == "requestPermission", ["granted", "denied", "restricted"].contains(source.permission) {
154
+ completion(.success(source.permission)); return
155
+ }
156
+ switch result {
157
+ case .success(let sample): completion(.success(method == "requestPermission" ? source.permission as Any : sample.payload))
158
+ case .failure(let error): completion(self.failure(["denied", "restricted"].contains(source.permission) ? "PERMISSION_DENIED" : "SENSOR_ERROR", error.localizedDescription))
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ private func date(_ value: Any) -> Double? {
165
+ guard let value = value as? NSNumber, CFGetTypeID(value) != CFBooleanGetTypeID(), value.doubleValue.isFinite else { return nil }
166
+ return value.doubleValue
167
+ }
168
+ func cancel(_ token: String) { pending.removeValue(forKey: token) }
169
+ @discardableResult func stop(reason: String) -> Bool {
170
+ guard let previous = live else { return false }
171
+ live = nil
172
+ previous.stop()
173
+ emit(["state": "stopped", "reason": reason])
174
+ return true
175
+ }
176
+ func close() { closed = true; pending.removeAll(); stop(reason: "disposed"); emit = { _ in } }
177
+ deinit { live?.stop() }
178
+ private func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
179
+ .failure(CraftNativeActionError(code: code, message: message))
180
+ }
181
+ }
@@ -6191,6 +6191,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6191
6191
  private var inputIdentities: [ObjectIdentifier: String] = [:]
6192
6192
  private var inputDrafts: [String: String] = [:]
6193
6193
  private var longPressHandlers: [ObjectIdentifier: String] = [:]
6194
+ private var accessibilityActionHandlers: [ObjectIdentifier: String] = [:]
6194
6195
  private var textMaxLengths: [ObjectIdentifier: Int] = [:]
6195
6196
  private var scrollHandlers: [ObjectIdentifier: String] = [:]
6196
6197
  private var scrollBeginHandlers: [ObjectIdentifier: String] = [:]
@@ -6259,6 +6260,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6259
6260
  private let audio = CraftWebAudio()
6260
6261
  /// `craft.audio`'s sounds and the streaming responses this screen reads.
6261
6262
  private let sounds = CraftNativeSounds()
6263
+ private let barometer = CraftNativeBarometer()
6264
+ private let pedometer = CraftNativePedometer()
6262
6265
  private let streams = CraftNativeEventStreams()
6263
6266
  /// A request a screen-owned module answers, by request token, and how to
6264
6267
  /// call it off when it times out or is cancelled.
@@ -6595,6 +6598,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6595
6598
  }
6596
6599
  audio.close()
6597
6600
  sounds.close()
6601
+ barometer.close()
6602
+ pedometer.close()
6598
6603
  streams.close()
6599
6604
  screenRequestCancellers.values.forEach { $0() }
6600
6605
  screenRequestCancellers.removeAll()
@@ -8037,6 +8042,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8037
8042
  guard let name = notification.userInfo?["event"] as? String else { return }
8038
8043
  self?.send(type: "CRAFT_EVENT", payload: ["event": name, "detail": notification.userInfo?["detail"] ?? [:]])
8039
8044
  })
8045
+ pedometer.emit = { [weak self] detail in self?.send(type: "CRAFT_EVENT", payload: ["event": "craftPedometerUpdate", "detail": detail]) }
8046
+ barometer.emit = { [weak self] detail in self?.send(type: "CRAFT_EVENT", payload: ["event": "craftBarometerUpdate", "detail": detail]) }
8040
8047
  sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
8041
8048
  streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
8042
8049
  sendAppState(CraftNativeActions.currentAppState())
@@ -8201,6 +8208,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8201
8208
  }
8202
8209
 
8203
8210
  private func sendAppState(_ state: String) {
8211
+ if state != "active" { barometer.stop(reason: "background"); pedometer.stop(reason: "background") }
8204
8212
  // A screen hears it while it is on top of its stack; the app script,
8205
8213
  // which is on no stack, always.
8206
8214
  guard isAppScript || navigationController?.topViewController === self else { return }
@@ -8412,6 +8420,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8412
8420
  }
8413
8421
  let id = args.first as? String ?? ""
8414
8422
  switch (module, method) {
8423
+ case ("Pedometer", _):
8424
+ if !config.enablePedometer {
8425
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Pedometer is disabled")))
8426
+ } else if isAppScript {
8427
+ reply(.failure(CraftNativeActionError(code: "NEEDS_ACTIVITY", message: "Pedometer needs a visible app")))
8428
+ } else if ["start", "requestPermission"].contains(method) && UIApplication.shared.applicationState != .active {
8429
+ reply(.failure(CraftNativeActionError(code: "APP_INACTIVE", message: "Pedometer needs a foreground app")))
8430
+ } else {
8431
+ screenRequestCancellers[requestToken] = { [weak self] in self?.pedometer.cancel(requestToken) }
8432
+ pedometer.perform(method: method, args: args, token: requestToken, completion: reply)
8433
+ }
8434
+ return true
8435
+ case ("Barometer", _):
8436
+ if !config.enableBarometer {
8437
+ reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Barometer is disabled")))
8438
+ } else if isAppScript {
8439
+ reply(.failure(CraftNativeActionError(code: "NEEDS_ACTIVITY", message: "Barometer needs a visible app")))
8440
+ } else if method == "start" && UIApplication.shared.applicationState != .active {
8441
+ reply(.failure(CraftNativeActionError(code: "APP_INACTIVE", message: "Barometer needs a foreground app")))
8442
+ } else {
8443
+ reply(barometer.perform(method: method, args: args))
8444
+ }
8445
+ return true
8415
8446
  case ("AudioRecording", _):
8416
8447
  guard config.enableAudioRecording else {
8417
8448
  reply(.failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Audio recording is disabled")))
@@ -9400,6 +9431,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9400
9431
  }
9401
9432
  result.craftPointerEvents = CraftNativePointerEvents.resolve(props: props, style: style)
9402
9433
  applyAccessibility(props, type: type, to: result)
9434
+ // After applyAccessibility: it settles whether the view is an element
9435
+ // at all, and an action on a non-element is unreachable.
9436
+ updateAccessibilityActions(props["accessibilityActions"], handler: props["disabled"] as? Bool == true ? nil : events["onAccessibilityAction"], for: result)
9403
9437
  (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
9404
9438
  return current
9405
9439
  }
@@ -9882,6 +9916,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9882
9916
  inputEventCounts.removeValue(forKey: id)
9883
9917
  if node.type == "TextInput" && !preservingInputDrafts { inputDrafts.removeValue(forKey: node.identity) }
9884
9918
  longPressHandlers.removeValue(forKey: id)
9919
+ accessibilityActionHandlers.removeValue(forKey: id)
9885
9920
  textMaxLengths.removeValue(forKey: id)
9886
9921
  scrollHandlers.removeValue(forKey: id)
9887
9922
  scrollBeginHandlers.removeValue(forKey: id)
@@ -10099,6 +10134,47 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
10099
10134
  }
10100
10135
  }
10101
10136
 
10137
+ /// What a hold offers, published where the rotor can reach it.
10138
+ ///
10139
+ /// A long press is invisible to VoiceOver: nothing announces that it is
10140
+ /// there and nothing can perform it, so an action living only behind a
10141
+ /// hold does not exist for anyone driving the screen by assistive
10142
+ /// technology. A thread that holds a message for Copy and Report is the
10143
+ /// case this was written for; without this the honest mitigation is a
10144
+ /// permanently visible button under every message.
10145
+ ///
10146
+ /// Shape follows the rest of the accessibility props: `name` identifies
10147
+ /// the action to the handler, `label` is what a person hears, and the
10148
+ /// event says which one ran so one handler can serve them all.
10149
+ private func updateAccessibilityActions(_ raw: Any?, handler: String?, for view: UIView) {
10150
+ let id = ObjectIdentifier(view)
10151
+ let entries = (raw as? [[String: Any]] ?? []).compactMap { entry -> (name: String, label: String)? in
10152
+ guard let name = entry["name"] as? String, !name.isEmpty else { return nil }
10153
+ return (name, (entry["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name)
10154
+ }
10155
+ guard let handler, !handler.isEmpty, !entries.isEmpty else {
10156
+ accessibilityActionHandlers.removeValue(forKey: id)
10157
+ view.accessibilityCustomActions = nil
10158
+ return
10159
+ }
10160
+ accessibilityActionHandlers[id] = handler
10161
+ view.accessibilityCustomActions = entries.map { entry in
10162
+ UIAccessibilityCustomAction(name: entry.label) { [weak self, weak view] _ in
10163
+ // Read the handler at firing time: a reconcile can rename it
10164
+ // while the rotor sits on the element.
10165
+ guard let self, let view, let current = self.accessibilityActionHandlers[ObjectIdentifier(view)] else { return false }
10166
+ self.send(type: "EVENT", payload: ["handlerName": current, "nativeEvent": ["actionName": entry.name]])
10167
+ return true
10168
+ }
10169
+ }
10170
+ // The rotor only lands on an element, and actions on a view it walks
10171
+ // straight through are actions nobody can get to. A bubble whose own
10172
+ // label is its text is exactly that case.
10173
+ if !view.isAccessibilityElement && view.accessibilityElements == nil && view.accessibilityLabel != nil {
10174
+ view.isAccessibilityElement = true
10175
+ }
10176
+ }
10177
+
10102
10178
  /// Legacy runtimes echo values without a revision. Keep their bounded
10103
10179
  /// history on either script thread; bridge delivery may still be delayed.
10104
10180
  private var unechoedText: [ObjectIdentifier: [String]] = [:]
@@ -285,8 +285,15 @@ final class DriverTests: XCTestCase {
285
285
  return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
286
286
  }
287
287
 
288
- /// A tap that never fails the run: an element covered a moment (the big
289
- /// timer coming up) is tapped where it is, and the check after says.
288
+ /// A switch reports "0" or "1". Any other value is not a switch state to
289
+ /// wait on, so the tap stands as it would for any other control.
290
+ func switchFlipped(from before: String?, to after: String?) -> Bool {
291
+ let states: Set<String> = ["0", "1"]
292
+ guard let before, let after, states.contains(before), states.contains(after) else { return true }
293
+ return before != after
294
+ }
295
+
296
+ /// Deliver one gesture; the command's observation determines success.
290
297
  func press(_ element: XCUIElement) {
291
298
  if element.isHittable { element.tap() }
292
299
  else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
@@ -438,8 +445,21 @@ final class DriverTests: XCTestCase {
438
445
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
439
446
  case "tapid":
440
447
  let target = identified(arg)
441
- if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
442
- else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
448
+ if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
449
+ else if target.elementType == .switch {
450
+ // Observe one physical gesture. Retapping could conceal a
451
+ // renderer or hit-testing failure (HQ agreement job 7570).
452
+ let before = target.value as? String
453
+ press(target)
454
+ let settle = Date().addingTimeInterval(1.5)
455
+ var flipped = false
456
+ repeat {
457
+ flipped = switchFlipped(from: before, to: target.value as? String)
458
+ if !flipped { Thread.sleep(forTimeInterval: 0.25) }
459
+ } while !flipped && Date() < settle
460
+ check(flipped, "tapid \(arg) flips the switch")
461
+ }
462
+ else { press(target); note("tapid \(arg): ok") }
443
463
  case "longpressid":
444
464
  let target = identified(arg)
445
465
  guard target.waitForExistence(timeout: 10), target.isHittable else {
@@ -43,7 +43,7 @@ import process from "process";
43
43
  var macosToolchain = {
44
44
  zig: "0.18.0-dev.252+86dc5ce25",
45
45
  sources: {
46
- "zig-js": { repository: "zig-utils/zig-js", revision: "59497a090677aeb317e52ac55329e6a680457727", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "95d835edcbad55a7456cff76a9bb7ef6cdf265fc", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
47
47
  "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
48
48
  "zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
49
49
  }
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
9
9
  readonly sources: {
10
10
  readonly 'zig-js': {
11
11
  readonly repository: 'zig-utils/zig-js';
12
- readonly revision: '59497a090677aeb317e52ac55329e6a680457727';
12
+ readonly revision: '95d835edcbad55a7456cff76a9bb7ef6cdf265fc';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {