serve-sim 0.1.44-beta.93.1 → 0.1.44-beta.99.3

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/Package.swift ADDED
@@ -0,0 +1,78 @@
1
+ // swift-tools-version:6.0
2
+ //
3
+ // Builds serve-sim-native — the in-process N-API addon that replaces the
4
+ // spawned serve-sim-bin helper. The JS bindings are written directly in Swift
5
+ // with node-swift (NodeAPI), so there is no Objective-C++ glue: SimHID /
6
+ // SimCapture are NodeClasses and the accessibility dumps are async NodeFunctions
7
+ // (see Sources/SimNative/sim-module.swift). The reverse-engineered streaming
8
+ // logic in SimStreamHelper is reused verbatim.
9
+ //
10
+ // The actual .node is produced by Sources/SimNative/build.sh, which drives
11
+ // `swift build --arch arm64 --arch x86_64` for a universal binary and links
12
+ // napi_* with `-undefined dynamic_lookup` (resolved against the host Node/Bun
13
+ // at dlopen). `node-swift rebuild` is intentionally not used: it builds a single
14
+ // arch only, and cross-compiling per-arch with `--triple` breaks the
15
+ // `#NodeModule` macro.
16
+
17
+ import PackageDescription
18
+
19
+ let package = Package(
20
+ name: "serve-sim-native",
21
+ platforms: [.macOS(.v14)],
22
+ products: [
23
+ .library(
24
+ name: "serve-sim-native",
25
+ type: .dynamic,
26
+ targets: ["SimNativeModule"]
27
+ ),
28
+ ],
29
+ dependencies: [
30
+ .package(path: "node_modules/node-swift"),
31
+ ],
32
+ targets: [
33
+ .target(
34
+ name: "SimNativeModule",
35
+ dependencies: [
36
+ .product(name: "NodeAPI", package: "node-swift"),
37
+ .product(name: "NodeModuleSupport", package: "node-swift"),
38
+ ],
39
+ path: "Sources",
40
+ // Sibling targets built by their own build.sh (Obj-C/ObjC++), not
41
+ // part of this SwiftPM package.
42
+ exclude: [
43
+ "SimCameraInjector",
44
+ "SimCameraHelper",
45
+ "SimAXSettings",
46
+ "SimNative/build.sh",
47
+ ],
48
+ sources: [
49
+ "SimNative/sim-module.swift",
50
+ "SimNative/sim-capture.swift",
51
+ "SimStreamHelper/HIDInjector.swift",
52
+ "SimStreamHelper/FrameCapture.swift",
53
+ "SimStreamHelper/VideoEncoder.swift",
54
+ "SimStreamHelper/H264Encoder.swift",
55
+ "SimStreamHelper/StreamFormat.swift",
56
+ "SimStreamHelper/AccessibilityBridge.swift",
57
+ "SimStreamHelper/SimFrameworks.swift",
58
+ "SimStreamHelper/Xcode.swift",
59
+ ],
60
+ linkerSettings: [
61
+ .linkedFramework("CoreVideo"),
62
+ .linkedFramework("CoreMedia"),
63
+ .linkedFramework("IOSurface"),
64
+ .linkedFramework("CoreGraphics"),
65
+ .linkedFramework("VideoToolbox"),
66
+ .linkedFramework("ImageIO"),
67
+ .linkedFramework("AppKit"),
68
+ ]
69
+ ),
70
+ ],
71
+ // The reused SimStreamHelper logic was written against the standalone helper
72
+ // (plain swiftc, which defaults to Swift 5 mode). Build in Swift 5 mode so its
73
+ // benign cross-queue captures stay warnings rather than Swift 6 errors;
74
+ // node-swift's API works in v5. Declared at package level so the target's
75
+ // language mode is valid under stricter toolchains — CI rejects a target
76
+ // language mode that isn't among the package's declared modes.
77
+ swiftLanguageModes: [.v5]
78
+ )
@@ -0,0 +1,51 @@
1
+ #!/bin/bash
2
+ # Builds serve-sim-native.node — the in-process N-API addon that replaces the
3
+ # spawned serve-sim-bin helper. The JS bindings are written in Swift with
4
+ # node-swift (see ../../Package.swift and sim-module.swift).
5
+ #
6
+ # Host-arch only (arm64 on Apple Silicon / CI). We use a plain `swift build`,
7
+ # i.e. the NATIVE SwiftPM build system, because that is the only mode that
8
+ # resolves node-swift's #NodeModule macro plugin: both `--arch X --arch Y`
9
+ # (universal) and `--triple <other-arch>` force Xcode's XCBuild, which fails to
10
+ # resolve the NodeAPIMacros plugin on stock toolchains ("missing target
11
+ # NodeAPIMacros" / "unable to resolve module SwiftSyntax"). Cross-compiling the
12
+ # x86_64 slice would therefore require an x86_64-native toolchain (Rosetta),
13
+ # which isn't available everywhere; serve-sim targets Apple Silicon. napi_* stay
14
+ # undefined and resolve against the host (Node/Bun) at dlopen via
15
+ # `-undefined dynamic_lookup`.
16
+ set -euo pipefail
17
+ HERE="$(cd "$(dirname "$0")" && pwd)"
18
+ PKG="$(cd "$HERE/../.." && pwd)" # packages/serve-sim (Package.swift root)
19
+ OUT_DIR="${1:-$PKG/dist/native}"
20
+ BUILD_DIR="$PKG/.build"
21
+ PRODUCT="serve-sim-native"
22
+ mkdir -p "$OUT_DIR"
23
+
24
+ if [ ! -d "$PKG/node_modules/node-swift" ]; then
25
+ echo "node-swift not found at $PKG/node_modules/node-swift (run: bun install)" >&2
26
+ exit 1
27
+ fi
28
+
29
+ swift build \
30
+ -c release \
31
+ --product "$PRODUCT" \
32
+ --package-path "$PKG" \
33
+ --build-path "$BUILD_DIR" \
34
+ -Xlinker -undefined -Xlinker dynamic_lookup
35
+
36
+ # `-print -quit` (not `| head`): under `pipefail`, head closing the pipe early
37
+ # would SIGPIPE find and fail the script. Match by name rather than a fixed
38
+ # subpath: the product dir varies by toolchain (native SwiftPM emits
39
+ # .build/release/, the Xcode build system emits .build/out/Products/Release/).
40
+ DYLIB="$(find "$BUILD_DIR" -name "lib${PRODUCT}.dylib" -type f -not -path '*.dSYM*' -print -quit)"
41
+ if [ -z "$DYLIB" ]; then
42
+ echo "Build succeeded but lib${PRODUCT}.dylib was not found under $BUILD_DIR" >&2
43
+ exit 1
44
+ fi
45
+
46
+ OUT="$OUT_DIR/${PRODUCT}.node"
47
+ cp "$DYLIB" "$OUT"
48
+ codesign -s - -f "$OUT" 2>/dev/null || true
49
+
50
+ echo "Built: $OUT"
51
+ lipo -info "$OUT"
@@ -0,0 +1,224 @@
1
+ import Foundation
2
+ import CoreVideo
3
+ import CoreMedia
4
+
5
+ // The capture + encode engine, reused verbatim from SimStreamHelper. Replicates
6
+ // main.swift's frameHandler: MJPEG always encodes while clients exist; H.264 runs
7
+ // only while AVCC is active. Encoded bytes (JPEG, or natively-framed AVCC
8
+ // envelopes) are handed back through a Swift closure on a native encode thread;
9
+ // the node-swift binding (sim-module.swift) marshals them onto the JS thread via
10
+ // a NodeAsyncQueue (threadsafe function).
11
+
12
+ /// (codec, data, width, height, flags) -> Void, invoked on a native encode
13
+ /// thread. codec: 0 = MJPEG, 1 = AVCC. flags (AVCC): bit0 = description,
14
+ /// bit1 = keyframe. `data` is a freshly-copied value safe to retain.
15
+ typealias SimFrameCallback = (Int32, Data, Int32, Int32, Int32) -> Void
16
+
17
+ final class CaptureEngine {
18
+ static let codecMJPEG: Int32 = 0
19
+ static let codecAVCC: Int32 = 1
20
+ static let flagDescription: Int32 = 1 << 0
21
+ static let flagKeyframe: Int32 = 1 << 1
22
+
23
+ private let deviceUDID: String
24
+ private let onFrame: SimFrameCallback
25
+
26
+ private let frameCapture = FrameCapture()
27
+ private let videoEncoder = VideoEncoder(quality: 0.7)
28
+ private let h264Encoder = H264Encoder(fps: 60)
29
+ private let encodeQueue = DispatchQueue(label: "napi.encode", qos: .userInteractive)
30
+ private let h264Queue = DispatchQueue(label: "napi.encode.h264", qos: .userInteractive)
31
+ private static let h264EncodeTimeoutMs = 500
32
+
33
+ // Mirrors main.swift's globals; mutated from the capture queue, read from the
34
+ // encode queues. Benign races (same pattern as the standalone helper).
35
+ private var screenWidth = 0
36
+ private var screenHeight = 0
37
+ private var encoderReady = false
38
+ private var encoding = false // MJPEG backpressure
39
+ private var h264Encoding = false // H.264 backpressure
40
+ private var forceKeyframe = false
41
+ private var avccActive = false
42
+ private var h264FrameToken: UInt64 = 0
43
+ private var started = false
44
+ private var stopped = false
45
+
46
+ init(deviceUDID: String, onFrame: @escaping SimFrameCallback) {
47
+ self.deviceUDID = deviceUDID
48
+ self.onFrame = onFrame
49
+
50
+ h264Encoder.onEncoded = { [weak self] encoded in
51
+ guard let self else { return }
52
+ if let description = encoded.description {
53
+ self.emit(codec: Self.codecAVCC,
54
+ data: AVCCEnvelope.description(avcc: description),
55
+ flags: Self.flagDescription)
56
+ }
57
+ switch encoded.kind {
58
+ case .keyframe:
59
+ self.emit(codec: Self.codecAVCC, data: AVCCEnvelope.keyframe(avcc: encoded.avcc),
60
+ flags: Self.flagKeyframe)
61
+ case .delta:
62
+ self.emit(codec: Self.codecAVCC, data: AVCCEnvelope.delta(avcc: encoded.avcc), flags: 0)
63
+ }
64
+ }
65
+ }
66
+
67
+ /// Hand encoded bytes to the binding. Gated by `stopped` so no callback fires
68
+ /// once teardown has begun.
69
+ private func emit(codec: Int32, data: Data, flags: Int32) {
70
+ if stopped { return }
71
+ onFrame(codec, data, Int32(screenWidth), Int32(screenHeight), flags)
72
+ }
73
+
74
+ func start() throws {
75
+ guard !started else { return }
76
+ // Latch `started` only after capture actually begins: if start() throws
77
+ // (e.g. device not booted), a later retry should still be allowed.
78
+ try frameCapture.start(deviceUDID: deviceUDID) { [weak self] pixelBuffer, _ in
79
+ self?.handleFrame(pixelBuffer)
80
+ }
81
+ started = true
82
+ }
83
+
84
+ private func handleFrame(_ pixelBuffer: CVPixelBuffer) {
85
+ let w = CVPixelBufferGetWidth(pixelBuffer)
86
+ let h = CVPixelBufferGetHeight(pixelBuffer)
87
+
88
+ if !encoderReady || w != screenWidth || h != screenHeight {
89
+ screenWidth = w
90
+ screenHeight = h
91
+ videoEncoder.stop()
92
+ videoEncoder.setup(width: Int32(w), height: Int32(h), fps: 60) { [weak self] jpeg in
93
+ self?.emit(codec: Self.codecMJPEG, data: jpeg, flags: 0)
94
+ }
95
+ encoderReady = true
96
+ }
97
+
98
+ let h264Request = reserveH264EncodeIfNeeded()
99
+ let shouldEncodeJpeg = encoderReady && !encoding
100
+ if !shouldEncodeJpeg && h264Request == nil { return }
101
+
102
+ guard let stableFrame = copyPixelBuffer(pixelBuffer) else {
103
+ if let h264Request {
104
+ finishH264Encode(token: h264Request.token, restoreKeyframe: h264Request.forceKeyframe)
105
+ }
106
+ return
107
+ }
108
+
109
+ if shouldEncodeJpeg {
110
+ encoding = true
111
+ encodeQueue.async { [weak self] in
112
+ guard let self else { return }
113
+ self.videoEncoder.encode(pixelBuffer: stableFrame)
114
+ self.encoding = false
115
+ }
116
+ }
117
+
118
+ // H.264 runs only while a viewer wants AVCC, so an all-MJPEG session pays
119
+ // no VideoToolbox cost.
120
+ if let h264Request {
121
+ h264Queue.async { [weak self] in
122
+ guard let self else { return }
123
+ self.h264Encoder.encode(stableFrame, forceKeyframe: h264Request.forceKeyframe) {
124
+ self.finishH264Encode(token: h264Request.token)
125
+ }
126
+ self.scheduleH264EncodeTimeout(token: h264Request.token)
127
+ }
128
+ }
129
+ }
130
+
131
+ /// Copy the live Simulator IOSurface immediately on the capture queue. The
132
+ /// encoders run later and SimulatorKit recycles/mutates that IOSurface in
133
+ /// place, so passing the wrapper CVPixelBuffer across queues can encode a
134
+ /// half-updated frame.
135
+ private func copyPixelBuffer(_ source: CVPixelBuffer) -> CVPixelBuffer? {
136
+ let width = CVPixelBufferGetWidth(source)
137
+ let height = CVPixelBufferGetHeight(source)
138
+ let pixelFormat = CVPixelBufferGetPixelFormatType(source)
139
+ let attrs: [String: Any] = [
140
+ kCVPixelBufferPixelFormatTypeKey as String: pixelFormat,
141
+ kCVPixelBufferWidthKey as String: width,
142
+ kCVPixelBufferHeightKey as String: height,
143
+ kCVPixelBufferCGImageCompatibilityKey as String: true,
144
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
145
+ ]
146
+ var out: CVPixelBuffer?
147
+ guard CVPixelBufferCreate(
148
+ kCFAllocatorDefault, width, height, pixelFormat, attrs as CFDictionary, &out
149
+ ) == kCVReturnSuccess, let dst = out else { return nil }
150
+
151
+ CVPixelBufferLockBaseAddress(source, .readOnly)
152
+ CVPixelBufferLockBaseAddress(dst, [])
153
+ defer {
154
+ CVPixelBufferUnlockBaseAddress(dst, [])
155
+ CVPixelBufferUnlockBaseAddress(source, .readOnly)
156
+ }
157
+ guard let srcAddr = CVPixelBufferGetBaseAddress(source),
158
+ let dstAddr = CVPixelBufferGetBaseAddress(dst) else { return nil }
159
+ let srcStride = CVPixelBufferGetBytesPerRow(source)
160
+ let dstStride = CVPixelBufferGetBytesPerRow(dst)
161
+ let rows = CVPixelBufferGetHeight(source)
162
+ let copyBytes = min(srcStride, dstStride)
163
+ for row in 0..<rows {
164
+ memcpy(dstAddr + row * dstStride, srcAddr + row * srcStride, copyBytes)
165
+ }
166
+ return dst
167
+ }
168
+
169
+ private func reserveH264EncodeIfNeeded() -> (forceKeyframe: Bool, token: UInt64)? {
170
+ h264Queue.sync {
171
+ guard avccActive, !h264Encoding else { return nil }
172
+ h264Encoding = true
173
+ h264FrameToken &+= 1
174
+ let token = h264FrameToken
175
+ let force = forceKeyframe
176
+ forceKeyframe = false
177
+ return (forceKeyframe: force, token: token)
178
+ }
179
+ }
180
+
181
+ private func finishH264Encode(token: UInt64, restoreKeyframe: Bool = false) {
182
+ h264Queue.async { [weak self] in
183
+ guard let self, self.h264FrameToken == token else { return }
184
+ self.h264Encoding = false
185
+ if restoreKeyframe { self.forceKeyframe = true }
186
+ }
187
+ }
188
+
189
+ private func scheduleH264EncodeTimeout(token: UInt64) {
190
+ h264Queue.asyncAfter(deadline: .now().advanced(by: .milliseconds(Self.h264EncodeTimeoutMs))) { [weak self] in
191
+ guard let self, self.h264FrameToken == token else { return }
192
+ self.h264Encoding = false
193
+ }
194
+ }
195
+
196
+ /// Toggle H.264 encoding. Turning it on forces the next frame to an IDR so a
197
+ /// freshly-connected decoder has a keyframe to start from.
198
+ func setAvccActive(_ active: Bool) {
199
+ h264Queue.async { [weak self] in
200
+ guard let self else { return }
201
+ if active && !self.avccActive { self.forceKeyframe = true }
202
+ self.avccActive = active
203
+ }
204
+ }
205
+
206
+ func requestKeyframe() {
207
+ h264Queue.async { [weak self] in self?.forceKeyframe = true }
208
+ }
209
+
210
+ func screenSize() -> (Int, Int) { (screenWidth, screenHeight) }
211
+
212
+ /// Halt frame production and drain the encode queues so no callback can fire
213
+ /// after this returns — the N-API layer relies on that before releasing the
214
+ /// threadsafe function.
215
+ func stop() {
216
+ if stopped { return }
217
+ stopped = true
218
+ frameCapture.stop()
219
+ encodeQueue.sync {}
220
+ h264Queue.sync {}
221
+ videoEncoder.stop()
222
+ h264Encoder.stop()
223
+ }
224
+ }
@@ -0,0 +1,195 @@
1
+ import Foundation
2
+ import NodeAPI
3
+
4
+ /// Safe Int→UInt32 for HID codes coming from JS. Plain `UInt32(x)` traps on
5
+ /// negative or too-large values, which would crash the in-process server on
6
+ /// malformed input; clamp out-of-range values to 0 (a harmless no-op code).
7
+ private func u32(_ v: Int) -> UInt32 {
8
+ UInt32(exactly: v) ?? 0
9
+ }
10
+
11
+ // node-swift entrypoint for serve-sim-native — the in-process N-API addon that
12
+ // replaces the spawned serve-sim-bin helper. The JS surface is expressed
13
+ // directly in Swift (no Objective-C++ glue): HID and frame capture are
14
+ // NodeClasses, and the accessibility dumps are async NodeFunctions. The
15
+ // reverse-engineered logic (HIDInjector, the CaptureEngine + encoders,
16
+ // AccessibilityBridge) is reused verbatim from SimStreamHelper.
17
+
18
+ // MARK: - HID
19
+
20
+ /// In-process HID injector for one simulator. Mirrors the WebSocket HID protocol
21
+ /// the spawned helper used to handle, as direct native calls. The instance is
22
+ /// released (freeing the injector) when its JS handle is garbage-collected.
23
+ @NodeClass @NodeActor final class SimHID {
24
+ private let injector: HIDInjector
25
+ private let udid: String
26
+
27
+ @NodeConstructor init(_ udid: String) throws {
28
+ self.udid = udid
29
+ injector = HIDInjector()
30
+ try injector.setup(deviceUDID: udid)
31
+ }
32
+
33
+ @NodeMethod func touch(_ type: String, _ x: Double, _ y: Double,
34
+ _ w: Int, _ h: Int, _ edge: Int) {
35
+ injector.sendTouch(type: type, x: x, y: y,
36
+ screenWidth: w, screenHeight: h, edge: u32(edge))
37
+ }
38
+
39
+ @NodeMethod func multiTouch(_ type: String, _ x1: Double, _ y1: Double,
40
+ _ x2: Double, _ y2: Double, _ w: Int, _ h: Int) {
41
+ injector.sendMultiTouch(type: type, x1: x1, y1: y1, x2: x2, y2: y2,
42
+ screenWidth: w, screenHeight: h)
43
+ }
44
+
45
+ @NodeMethod func button(_ button: String) {
46
+ injector.sendButton(button: button, deviceUDID: udid)
47
+ }
48
+
49
+ @NodeMethod func buttonHid(_ page: Int, _ usage: Int, _ phase: String) {
50
+ injector.sendButtonHID(page: u32(page), usage: u32(usage), phase: phase)
51
+ }
52
+
53
+ @NodeMethod func key(_ type: String, _ usage: Int) {
54
+ injector.sendKey(type: type, usage: u32(usage))
55
+ }
56
+
57
+ /// NaN anchorX/anchorY mean "center" (the Swift API's nil).
58
+ @NodeMethod func scroll(_ dx: Double, _ dy: Double,
59
+ _ anchorX: Double, _ anchorY: Double, _ w: Int, _ h: Int) {
60
+ injector.sendScroll(dx: dx, dy: dy,
61
+ anchorX: anchorX.isNaN ? nil : anchorX,
62
+ anchorY: anchorY.isNaN ? nil : anchorY,
63
+ screenWidth: w, screenHeight: h)
64
+ }
65
+
66
+ @NodeMethod func digitalCrown(_ delta: Double) {
67
+ injector.sendDigitalCrown(delta: delta)
68
+ }
69
+
70
+ @NodeMethod func orientation(_ orientation: Int) -> Bool {
71
+ injector.sendOrientation(orientation: u32(orientation))
72
+ }
73
+
74
+ @NodeMethod func memoryWarning() {
75
+ injector.simulateMemoryWarning()
76
+ }
77
+
78
+ @NodeMethod func softwareKeyboard() {
79
+ injector.toggleSoftwareKeyboard()
80
+ }
81
+
82
+ @NodeMethod func caDebug(_ name: String, _ enabled: Bool) -> Bool {
83
+ injector.setCADebugOption(name: name, enabled: enabled)
84
+ }
85
+ }
86
+
87
+ // MARK: - Capture
88
+
89
+ /// In-process frame capture + encode for one simulator. MJPEG frames are always
90
+ /// produced; H.264/AVCC runs only while `setAvccActive(true)`. Encoded frames are
91
+ /// produced on a native encode thread and marshalled onto the JS thread through a
92
+ /// NodeAsyncQueue (threadsafe function), then handed to `onFrame` as
93
+ /// (codec, Buffer, width, height, flags).
94
+ @NodeClass @NodeActor final class SimCapture {
95
+ private let engine: CaptureEngine
96
+ private let onFrame: NodeFunction
97
+ private let queue: NodeAsyncQueue
98
+
99
+ @NodeConstructor init(_ udid: String, _ onFrame: NodeFunction) throws {
100
+ // unref'd by NodeAsyncQueue's init, so the frame pipeline alone won't
101
+ // keep the event loop alive. Bounded queue + blocking AVCC preserves
102
+ // inter-frame ordering; MJPEG is nonblocking and drops under backpressure.
103
+ let queue = try NodeAsyncQueue(label: "simCapture", maxQueueSize: 16)
104
+ self.onFrame = onFrame
105
+ self.queue = queue
106
+
107
+ // Capture the locals (not self) so the closure can be built before the
108
+ // engine property is initialized, and so it holds no strong ref to self.
109
+ engine = CaptureEngine(deviceUDID: udid) { codec, data, w, h, flags in
110
+ // Runs on a native encode thread. AVCC is inter-frame H.264 — dropping
111
+ // a delta corrupts the decoder until the next IDR — so deliver it
112
+ // blocking; MJPEG is stateless and safe to drop. We copy the bytes
113
+ // into a managed Buffer (NodeBuffer(copying:)) on the JS thread:
114
+ // external buffers crash Bun's GC under frame churn, and the
115
+ // production CLI is a bun-compiled binary.
116
+ let blocking = codec == CaptureEngine.codecAVCC
117
+ try? queue.run(blocking: blocking) {
118
+ _ = try? onFrame.call([
119
+ Int(codec), try NodeBuffer(copying: data),
120
+ Int(w), Int(h), Int(flags),
121
+ ])
122
+ }
123
+ }
124
+ }
125
+
126
+ @NodeMethod func start() throws {
127
+ try engine.start()
128
+ }
129
+
130
+ @NodeMethod func setAvccActive(_ active: Bool) {
131
+ engine.setAvccActive(active)
132
+ }
133
+
134
+ @NodeMethod func requestKeyframe() {
135
+ engine.requestKeyframe()
136
+ }
137
+
138
+ @NodeMethod func screenSize() -> [String: any NodePropertyConvertible] {
139
+ let (w, h) = engine.screenSize()
140
+ return ["width": w, "height": h]
141
+ }
142
+
143
+ @NodeMethod func stop() {
144
+ engine.stop()
145
+ }
146
+
147
+ deinit {
148
+ // Abort the queue first so any encode thread blocked in `run` unblocks
149
+ // (its call returns .closing and the frame is dropped); then drain the
150
+ // encoders so nothing can fire afterwards. The tsfn is released when
151
+ // `queue` deinitializes after this body.
152
+ try? queue.close()
153
+ engine.stop()
154
+ }
155
+ }
156
+
157
+ // MARK: - Accessibility
158
+
159
+ /// Run a blocking accessibility query off the JS event loop (on a background
160
+ /// queue) and resolve with its result, mirroring the old napi_async_work path.
161
+ private func axQuery(
162
+ _ udid: String, _ body: @escaping @Sendable (String) throws -> String
163
+ ) async throws -> String {
164
+ try await withCheckedThrowingContinuation { cont in
165
+ DispatchQueue.global(qos: .userInitiated).async {
166
+ do {
167
+ cont.resume(returning: try body(udid))
168
+ } catch {
169
+ cont.resume(throwing: error)
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ #NodeModule(exports: [
176
+ "SimHID": SimHID.deferredConstructor,
177
+ "SimCapture": SimCapture.deferredConstructor,
178
+ // axDescribe(udid): Promise<string> — axe-shaped accessibility JSON.
179
+ "axDescribe": try NodeFunction { (udid: String) async throws -> String in
180
+ try await axQuery(udid) { udid in
181
+ SimFrameworks.load() // /ax may be hit before capture/HID load them
182
+ let data = try AccessibilityBridge.shared.describeUI(udid: udid)
183
+ return String(decoding: data, as: UTF8.self)
184
+ }
185
+ },
186
+ // axFrontmost(udid): Promise<string> — JSON `{ bundleId, pid }`.
187
+ "axFrontmost": try NodeFunction { (udid: String) async throws -> String in
188
+ try await axQuery(udid) { udid in
189
+ SimFrameworks.load()
190
+ let info = try AccessibilityBridge.shared.frontmostApp(udid: udid)
191
+ let data = try JSONSerialization.data(withJSONObject: info)
192
+ return String(decoding: data, as: UTF8.self)
193
+ }
194
+ },
195
+ ])