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 +78 -0
- package/Sources/SimNative/build.sh +51 -0
- package/Sources/SimNative/sim-capture.swift +224 -0
- package/Sources/SimNative/sim-module.swift +195 -0
- package/Sources/SimStreamHelper/AccessibilityBridge.swift +635 -0
- package/Sources/SimStreamHelper/FrameCapture.swift +310 -0
- package/Sources/SimStreamHelper/H264Encoder.swift +261 -0
- package/Sources/SimStreamHelper/HIDInjector.swift +649 -0
- package/Sources/SimStreamHelper/SimFrameworks.swift +25 -0
- package/Sources/SimStreamHelper/StreamFormat.swift +46 -0
- package/Sources/SimStreamHelper/VideoEncoder.swift +53 -0
- package/Sources/SimStreamHelper/Xcode.swift +25 -0
- package/dist/middleware.js +43 -35
- package/dist/native/serve-sim-native.node +0 -0
- package/dist/serve-sim.js +85 -78
- package/package.json +11 -4
- package/src/ax.ts +27 -63
- package/src/middleware.ts +167 -134
- package/src/native.ts +240 -0
- package/src/state.ts +49 -1
- package/bin/serve-sim-bin +0 -0
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
|
+
])
|