@appium/coresim 1.5.0 → 1.7.0

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 (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -0
  3. package/binding.gyp +7 -0
  4. package/lib/src/commands/video-recording.d.ts +26 -10
  5. package/lib/src/commands/video-recording.d.ts.map +1 -1
  6. package/lib/src/commands/video-recording.js +55 -16
  7. package/lib/src/commands/video-recording.js.map +1 -1
  8. package/lib/src/commands/video-stream.d.ts +55 -7
  9. package/lib/src/commands/video-stream.d.ts.map +1 -1
  10. package/lib/src/commands/video-stream.js +37 -35
  11. package/lib/src/commands/video-stream.js.map +1 -1
  12. package/lib/src/index.d.ts +1 -1
  13. package/lib/src/index.d.ts.map +1 -1
  14. package/lib/src/index.js.map +1 -1
  15. package/lib/src/native-simctl.js +12 -1
  16. package/lib/src/native-simctl.js.map +1 -1
  17. package/lib/src/types.d.ts +82 -10
  18. package/lib/src/types.d.ts.map +1 -1
  19. package/lib/src/utils/index.d.ts +1 -1
  20. package/lib/src/utils/index.d.ts.map +1 -1
  21. package/lib/src/utils/index.js +1 -1
  22. package/lib/src/utils/index.js.map +1 -1
  23. package/lib/src/utils/run-catching.d.ts +4 -0
  24. package/lib/src/utils/run-catching.d.ts.map +1 -1
  25. package/lib/src/utils/run-catching.js +11 -0
  26. package/lib/src/utils/run-catching.js.map +1 -1
  27. package/package.json +1 -1
  28. package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
  29. package/src/commands/video-recording.ts +75 -25
  30. package/src/commands/video-stream.ts +48 -39
  31. package/src/coresim.mm +547 -118
  32. package/src/index.ts +1 -0
  33. package/src/native/audio_encoder.h +65 -0
  34. package/src/native/audio_encoder.mm +270 -0
  35. package/src/native/av_recording.h +52 -0
  36. package/src/native/av_recording.mm +424 -0
  37. package/src/native/av_stream.h +70 -0
  38. package/src/native/av_stream.mm +217 -0
  39. package/src/native/monotonic_clock.h +20 -0
  40. package/src/native/sim_audio_tap.h +67 -0
  41. package/src/native/sim_audio_tap.mm +350 -0
  42. package/src/native/sim_process.h +10 -0
  43. package/src/native/sim_process.mm +81 -18
  44. package/src/native/sim_video_stream.h +14 -11
  45. package/src/native/sim_video_stream.mm +27 -383
  46. package/src/native/video_encoder.h +76 -0
  47. package/src/native/video_encoder.mm +417 -0
  48. package/src/native-simctl.ts +12 -1
  49. package/src/types.ts +95 -11
  50. package/src/utils/index.ts +1 -1
  51. package/src/utils/run-catching.ts +11 -0
@@ -0,0 +1,217 @@
1
+ #include "av_stream.h"
2
+
3
+ #include <atomic>
4
+ #include <mutex>
5
+
6
+ #include "audio_encoder.h"
7
+ #include "monotonic_clock.h"
8
+ #include "sim_audio_tap.h"
9
+
10
+ namespace coresim {
11
+
12
+ class AVStreamSession::Impl {
13
+ public:
14
+ Impl(id device, NSString* udid, VideoEncoderOptions videoOptions, std::function<void(AVAccessUnit)> onAccessUnit,
15
+ std::function<void(NSError*)> onError, std::function<void()> onEnd, std::function<void()> onAbortDelivery)
16
+ : device_(device),
17
+ udid_(udid),
18
+ videoOptions_(videoOptions),
19
+ onAccessUnit_(std::move(onAccessUnit)),
20
+ onError_(std::move(onError)),
21
+ onEnd_(std::move(onEnd)),
22
+ onAbortDelivery_(std::move(onAbortDelivery)) {}
23
+
24
+ ~Impl() { StopInternal(); }
25
+
26
+ void Start() {
27
+ clockOrigin_ = MonotonicSeconds();
28
+ running_ = true;
29
+ try {
30
+ audioTap_ = std::make_unique<AudioTapSession>(
31
+ udid_, [this](const AudioBufferList* data, const AudioTimeStamp* time) { HandleAudioPCM(data, time); },
32
+ [this](NSError* error) {
33
+ // A failed process-list refresh (kAudioTapNonFatalProcessListRefreshErrorCode) isn't
34
+ // fatal — the tap keeps running. Everything else is.
35
+ if ([error.domain isEqualToString:kAudioTapErrorDomain] &&
36
+ error.code == kAudioTapNonFatalProcessListRefreshErrorCode) {
37
+ return;
38
+ }
39
+ Fail(error, /*fromVideo=*/false);
40
+ },
41
+ [] {});
42
+ audioTap_->Start();
43
+
44
+ // Start() can invoke HandleAudioPCM before returning, so audioEncoder_ is written under
45
+ // audioEncoderMutex_ (same lock HandleAudioPCM reads it under) to avoid a torn/racy read.
46
+ auto encoder = std::make_unique<AudioEncoder>(
47
+ audioTap_->Format(), [this](CMSampleBufferRef sampleBuffer) { HandleAudioSample(sampleBuffer); },
48
+ &clockOrigin_);
49
+ {
50
+ std::lock_guard<std::mutex> lock(audioEncoderMutex_);
51
+ audioEncoder_ = std::move(encoder);
52
+ }
53
+
54
+ videoEncoder_ = std::make_unique<VideoFrameEncoder>(
55
+ device_, videoOptions_, [this](CMSampleBufferRef sampleBuffer) { HandleVideoSample(sampleBuffer); },
56
+ [this](NSError* error) { Fail(error, /*fromVideo=*/true); }, [] {}, &clockOrigin_);
57
+ videoEncoder_->Start();
58
+ } catch (...) {
59
+ running_ = false;
60
+ StopInternal();
61
+ throw;
62
+ }
63
+ }
64
+
65
+ void Stop() { StopInternal(); }
66
+
67
+ void AbortDelivery() {
68
+ if (onAbortDelivery_) {
69
+ onAbortDelivery_();
70
+ }
71
+ }
72
+
73
+ void RequestKeyFrame() {
74
+ if (videoEncoder_) {
75
+ videoEncoder_->RequestKeyFrame();
76
+ }
77
+ }
78
+
79
+ private:
80
+ // Runs on AudioTapSession's own queue; audioEncoder_ is guarded by audioEncoderMutex_ (see
81
+ // Start()). A throw from EncodePCM is left to propagate — AudioTapSession::HandleBuffer catches
82
+ // it and self-stops (see sim_audio_tap.h's onBuffer doc) — never call audioTap_->Stop() here.
83
+ void HandleAudioPCM(const AudioBufferList* data, const AudioTimeStamp* time) {
84
+ AudioEncoder* encoder;
85
+ {
86
+ std::lock_guard<std::mutex> lock(audioEncoderMutex_);
87
+ if (!running_) {
88
+ return;
89
+ }
90
+ encoder = audioEncoder_.get();
91
+ }
92
+ if (encoder != nullptr) {
93
+ encoder->EncodePCM(data, time);
94
+ }
95
+ }
96
+
97
+ void HandleVideoSample(CMSampleBufferRef sampleBuffer) {
98
+ if (!running_) {
99
+ return;
100
+ }
101
+ bool isKeyFrame = IsKeyFrame(sampleBuffer);
102
+ CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
103
+ AVAccessUnit unit;
104
+ unit.track = AVTrack::kVideo;
105
+ unit.isKeyFrame = isKeyFrame;
106
+ unit.sequence = videoSequence_++;
107
+ unit.timestampMicros = pts.timescale != 0 ? (pts.value * 1000000 / pts.timescale) : 0;
108
+ RepackAsAnnexB(unit.data, sampleBuffer, isKeyFrame, videoOptions_.codec);
109
+ if (onAccessUnit_) {
110
+ onAccessUnit_(std::move(unit));
111
+ }
112
+ }
113
+
114
+ void HandleAudioSample(CMSampleBufferRef sampleBuffer) {
115
+ if (!running_) {
116
+ return;
117
+ }
118
+ CMBlockBufferRef block = CMSampleBufferGetDataBuffer(sampleBuffer);
119
+ if (block == nullptr) {
120
+ return;
121
+ }
122
+ size_t length = CMBlockBufferGetDataLength(block);
123
+ AVAccessUnit unit;
124
+ unit.track = AVTrack::kAudio;
125
+ unit.isKeyFrame = true; // every AAC-LC packet is independently decodable
126
+ unit.sequence = audioSequence_++;
127
+ CMTime pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
128
+ unit.timestampMicros = pts.timescale != 0 ? (pts.value * 1000000 / pts.timescale) : 0;
129
+ // ADTS-framed (not bare AAC) so each packet self-describes its sample rate/channel count — a
130
+ // streaming consumer has no other way to learn those, unlike the muxed-file recording path
131
+ // (av_recording.h), which hands AVAssetWriter the format directly.
132
+ PrependADTSHeader(unit.data, length, audioTap_->Format());
133
+ size_t headerSize = unit.data.size();
134
+ unit.data.resize(headerSize + length);
135
+ if (CMBlockBufferCopyDataBytes(block, 0, length, unit.data.data() + headerSize) != kCMBlockBufferNoErr) {
136
+ return;
137
+ }
138
+ if (onAccessUnit_) {
139
+ onAccessUnit_(std::move(unit));
140
+ }
141
+ }
142
+
143
+ // Called from either encoder's onError. Stops only the OTHER (still-running) one — the failing
144
+ // one is already tearing itself down on its own error path, and calling its own blocking Stop()
145
+ // from within its own callback would deadlock.
146
+ void Fail(NSError* error, bool fromVideo) {
147
+ if (!running_.exchange(false)) {
148
+ return; // already stopping/stopped
149
+ }
150
+ if (fromVideo) {
151
+ if (audioTap_) {
152
+ audioTap_->Stop();
153
+ }
154
+ } else {
155
+ if (videoEncoder_) {
156
+ videoEncoder_->Stop();
157
+ }
158
+ }
159
+ if (onError_) {
160
+ onError_(error);
161
+ }
162
+ if (onEnd_) {
163
+ onEnd_();
164
+ }
165
+ }
166
+
167
+ void StopInternal() {
168
+ if (!running_.exchange(false)) {
169
+ return; // idempotent
170
+ }
171
+ if (videoEncoder_) {
172
+ videoEncoder_->Stop();
173
+ }
174
+ if (audioTap_) {
175
+ audioTap_->Stop();
176
+ }
177
+ if (onEnd_) {
178
+ onEnd_();
179
+ }
180
+ }
181
+
182
+ id device_;
183
+ NSString* udid_;
184
+ VideoEncoderOptions videoOptions_;
185
+ std::function<void(AVAccessUnit)> onAccessUnit_;
186
+ std::function<void(NSError*)> onError_;
187
+ std::function<void()> onEnd_;
188
+ std::function<void()> onAbortDelivery_;
189
+
190
+ double clockOrigin_ = 0;
191
+ std::unique_ptr<VideoFrameEncoder> videoEncoder_;
192
+ std::unique_ptr<AudioTapSession> audioTap_;
193
+ std::mutex audioEncoderMutex_; // guards audioEncoder_ — see HandleAudioPCM/Start()
194
+ std::unique_ptr<AudioEncoder> audioEncoder_;
195
+
196
+ std::atomic<uint64_t> videoSequence_{0};
197
+ std::atomic<uint64_t> audioSequence_{0};
198
+ std::atomic<bool> running_{false};
199
+ };
200
+
201
+ AVStreamSession::AVStreamSession(id device, NSString* udid, VideoEncoderOptions videoOptions,
202
+ std::function<void(AVAccessUnit)> onAccessUnit, std::function<void(NSError*)> onError,
203
+ std::function<void()> onEnd, std::function<void()> onAbortDelivery)
204
+ : impl_(std::make_unique<Impl>(device, udid, videoOptions, std::move(onAccessUnit), std::move(onError),
205
+ std::move(onEnd), std::move(onAbortDelivery))) {}
206
+
207
+ AVStreamSession::~AVStreamSession() = default;
208
+
209
+ void AVStreamSession::Start() { impl_->Start(); }
210
+
211
+ void AVStreamSession::Stop() { impl_->Stop(); }
212
+
213
+ void AVStreamSession::AbortDelivery() { impl_->AbortDelivery(); }
214
+
215
+ void AVStreamSession::RequestKeyFrame() { impl_->RequestKeyFrame(); }
216
+
217
+ } // namespace coresim
@@ -0,0 +1,20 @@
1
+ #pragma once
2
+
3
+ #include <ctime>
4
+
5
+ namespace coresim {
6
+
7
+ // CFAbsoluteTimeGetCurrent()/gettimeofday() are wall time — subject to backward NTP/manual clock
8
+ // corrections, which would violate encoders' requirement of strictly increasing presentation
9
+ // timestamps. CLOCK_MONOTONIC_RAW never goes backward and isn't adjusted by NTP. Shared by
10
+ // video_encoder.mm, audio_encoder.mm, and av_recording.mm/av_stream.mm — the latter two capture
11
+ // one value from this up front and hand it to both encoders as their common PTS-zero reference,
12
+ // so a combined recording/stream's video and audio tracks stay in sync instead of each measuring
13
+ // elapsed time from its own, independently-timed setup completion.
14
+ inline double MonotonicSeconds() {
15
+ struct timespec ts;
16
+ clock_gettime(CLOCK_MONOTONIC_RAW, &ts);
17
+ return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
18
+ }
19
+
20
+ } // namespace coresim
@@ -0,0 +1,67 @@
1
+ #pragma once
2
+
3
+ #import <CoreAudio/CoreAudio.h>
4
+ #import <Foundation/Foundation.h>
5
+
6
+ #include <functional>
7
+ #include <memory>
8
+
9
+ namespace coresim {
10
+
11
+ // Domain of every NSError AudioTapSession reports via its `onError` callback.
12
+ extern NSString* const kAudioTapErrorDomain;
13
+
14
+ // The one `onError` code that does NOT mean the session stopped itself: a failed attempt to
15
+ // refresh the tapped process list — it keeps running on the stale set and retries next poll tick.
16
+ // Every other code means the session already tore itself down. Callers that otherwise treat every
17
+ // `onError` as fatal must special-case this one.
18
+ constexpr NSInteger kAudioTapNonFatalProcessListRefreshErrorCode = 9;
19
+
20
+ // Captures one simulator's audio via macOS's public Core Audio "process tap" API
21
+ // (CATapDescription + AudioHardwareCreateProcessTap, macOS 14.2+), scoped to the live set of host
22
+ // PIDs belonging to that device's guest process tree (SpringBoard + any launched app — see
23
+ // sim_process.h's FindGuestProcessPids). CoreSimulator itself has no audio *capture* API at all
24
+ // (see CLAUDE.md) — this taps the guest's own processes directly, isolating one simulator's audio
25
+ // from other simulators and from unrelated Mac apps.
26
+ //
27
+ // IMPORTANT — host TCC permission required, not obtainable programmatically: creating a process
28
+ // tap for another process's audio requires the host's "System Audio Recording Only" permission
29
+ // (kTCCServiceAudioCapture), granted once via System Settings > Privacy & Security. Unlike every
30
+ // other permission this addon handles, this one is keyed to the host Node process's code-signing
31
+ // identity, lives in the host's SIP-protected system TCC database (no direct-write workaround like
32
+ // tcc_privacy.mm's), and has no query API — a denial isn't a catchable error, it's silent
33
+ // all-zero PCM. See CLAUDE.md for the full writeup; this is a documented limitation, not a bug.
34
+ class AudioTapSession {
35
+ public:
36
+ // `onBuffer` delivers each IO cycle's tapped PCM, in the tap's own AudioStreamBasicDescription
37
+ // (query via Format() after Start()) — valid only for the duration of the call, copy out
38
+ // anything needed beyond it. If it throws, the session catches it, reports via `onError`, and
39
+ // stops itself — no try/catch needed in `onBuffer`, and it must never call this session's own
40
+ // Stop() (runs on the session's internal queue — would deadlock). `onError`/`onEnd` mirror
41
+ // VideoFrameEncoder's contract, except `onError` can also fire non-fatally — see
42
+ // kAudioTapNonFatalProcessListRefreshErrorCode.
43
+ AudioTapSession(NSString* udid, std::function<void(const AudioBufferList*, const AudioTimeStamp*)> onBuffer,
44
+ std::function<void(NSError*)> onError, std::function<void()> onEnd);
45
+ ~AudioTapSession();
46
+
47
+ AudioTapSession(const AudioTapSession&) = delete;
48
+ AudioTapSession& operator=(const AudioTapSession&) = delete;
49
+
50
+ // Resolves the device's current guest process set, creates the tap + aggregate device, and
51
+ // starts IO. Throws synchronously on failure (`onEnd` never called then) — including
52
+ // NativeSimUnavailableError if this macOS predates process taps (< 14.2), and a plain
53
+ // NSErrorException if the guest process set is empty (device not really booted).
54
+ void Start();
55
+
56
+ // Idempotent; blocks until IO has fully stopped and the tap/aggregate device are destroyed.
57
+ void Stop();
58
+
59
+ // The tap's live PCM format — valid only after Start() returns.
60
+ const AudioStreamBasicDescription& Format() const;
61
+
62
+ private:
63
+ class Impl;
64
+ std::unique_ptr<Impl> impl_;
65
+ };
66
+
67
+ } // namespace coresim
@@ -0,0 +1,350 @@
1
+ #include "sim_audio_tap.h"
2
+
3
+ #import <CoreAudio/AudioHardwareTapping.h>
4
+ #import <CoreAudio/CATapDescription.h>
5
+
6
+ #include <algorithm>
7
+ #include <atomic>
8
+
9
+ #include "nserror_bridge.h"
10
+ #include "sim_process.h"
11
+
12
+ namespace coresim {
13
+
14
+ NSString* const kAudioTapErrorDomain = @"com.appium.coresim.AudioTap";
15
+
16
+ namespace {
17
+
18
+ NSError* MakeError(NSInteger code, NSString* message) {
19
+ return [NSError errorWithDomain:kAudioTapErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
20
+ }
21
+
22
+ NSError* MakeStatusError(NSInteger code, NSString* what, OSStatus status) {
23
+ return MakeError(code, [NSString stringWithFormat:@"%@ (OSStatus %d)", what, static_cast<int>(status)]);
24
+ }
25
+
26
+ // AudioObjectID for `pid`'s Core Audio process object, or kAudioObjectUnknown (0) if it hasn't
27
+ // registered with the audio HAL yet (e.g. a just-launched app that hasn't touched audio) — not an
28
+ // error per kAudioHardwarePropertyTranslatePIDToProcessObject's own documented contract.
29
+ AudioObjectID TranslatePidToProcessObject(pid_t pid) {
30
+ AudioObjectPropertyAddress address = {kAudioHardwarePropertyTranslatePIDToProcessObject,
31
+ kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain};
32
+ AudioObjectID objectID = kAudioObjectUnknown;
33
+ UInt32 dataSize = sizeof(objectID);
34
+ pid_t qualifierPid = pid;
35
+ OSStatus status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &address, sizeof(qualifierPid), &qualifierPid,
36
+ &dataSize, &objectID);
37
+ return status == noErr ? objectID : kAudioObjectUnknown;
38
+ }
39
+
40
+ // The live, audio-HAL-registered process set for `udid` — skips any guest pid that hasn't
41
+ // touched the audio HAL yet. Sorted so two resolutions can be compared with plain `==`.
42
+ std::vector<AudioObjectID> ResolveProcessObjects(NSString* udid) {
43
+ std::vector<AudioObjectID> result;
44
+ for (pid_t pid : FindGuestProcessPids(udid)) {
45
+ AudioObjectID objectID = TranslatePidToProcessObject(pid);
46
+ if (objectID != kAudioObjectUnknown) {
47
+ result.push_back(objectID);
48
+ }
49
+ }
50
+ std::sort(result.begin(), result.end());
51
+ return result;
52
+ }
53
+
54
+ NSArray<NSNumber*>* ToNumberArray(const std::vector<AudioObjectID>& objectIDs) {
55
+ NSMutableArray<NSNumber*>* array = [NSMutableArray arrayWithCapacity:objectIDs.size()];
56
+ for (AudioObjectID objectID : objectIDs) {
57
+ [array addObject:@(objectID)];
58
+ }
59
+ return array;
60
+ }
61
+
62
+ } // namespace
63
+
64
+ class AudioTapSession::Impl {
65
+ public:
66
+ Impl(NSString* udid, std::function<void(const AudioBufferList*, const AudioTimeStamp*)> onBuffer,
67
+ std::function<void(NSError*)> onError, std::function<void()> onEnd)
68
+ : udid_(udid), onBuffer_(std::move(onBuffer)), onError_(std::move(onError)), onEnd_(std::move(onEnd)) {
69
+ queue_ = dispatch_queue_create("com.appium.coresim.audioTap", DISPATCH_QUEUE_SERIAL);
70
+ }
71
+
72
+ ~Impl() { Stop(); }
73
+
74
+ void Start() {
75
+ if (@available(macOS 14.2, *)) {
76
+ StartTap();
77
+ } else {
78
+ throw NSErrorException(
79
+ MakeError(1, @"Core Audio process taps require macOS 14.2 or later — this host's macOS predates it"));
80
+ }
81
+ }
82
+
83
+ // Callable from any thread except `queue_` itself (would deadlock on the dispatch_sync below).
84
+ void Stop() {
85
+ if (!running_.exchange(false)) {
86
+ return; // idempotent
87
+ }
88
+ if (pollTimer_ != nullptr) {
89
+ dispatch_source_cancel(pollTimer_);
90
+ // Blocks until any in-flight poll tick finishes — by then running_ is already false.
91
+ dispatch_sync(queue_, ^{
92
+ });
93
+ pollTimer_ = nullptr;
94
+ }
95
+ TearDown();
96
+ if (onEnd_) {
97
+ onEnd_();
98
+ }
99
+ }
100
+
101
+ const AudioStreamBasicDescription& Format() const { return format_; }
102
+
103
+ private:
104
+ API_AVAILABLE(macos(14.2))
105
+ void StartTap() {
106
+ // A host with no default audio output device at all can't do any of this — checked up front
107
+ // so that case fails in milliseconds. Doesn't cover every slow-CI-host failure mode: some
108
+ // runners with a real default device still make AudioDeviceStart below block for ~180s before
109
+ // failing with MACH_RCV_TIMED_OUT (a Mach IPC timeout talking to coreaudiod) — see CLAUDE.md.
110
+ // That one isn't predictable/avoidable from here.
111
+ AudioObjectID defaultOutput = kAudioObjectUnknown;
112
+ UInt32 defaultOutputSize = sizeof(defaultOutput);
113
+ AudioObjectPropertyAddress defaultOutputAddress = {
114
+ kAudioHardwarePropertyDefaultOutputDevice, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain};
115
+ OSStatus defaultOutputStatus = AudioObjectGetPropertyData(kAudioObjectSystemObject, &defaultOutputAddress, 0,
116
+ nullptr, &defaultOutputSize, &defaultOutput);
117
+ if (defaultOutputStatus != noErr || defaultOutput == kAudioObjectUnknown) {
118
+ throw NSErrorException(
119
+ MakeError(11, @"This host has no default audio output device — Core Audio process taps need one"));
120
+ }
121
+
122
+ lastProcessSet_ = ResolveProcessObjects(udid_);
123
+ if (lastProcessSet_.empty()) {
124
+ throw NSErrorException(MakeError(
125
+ 2, [NSString stringWithFormat:@"No audio-capturable guest processes were found for device '%@' — is it "
126
+ @"booted, and has it produced any audio yet?",
127
+ udid_]));
128
+ }
129
+ NSUUID* uuid = [NSUUID UUID];
130
+ CATapDescription* description =
131
+ [[CATapDescription alloc] initStereoMixdownOfProcesses:ToNumberArray(lastProcessSet_)];
132
+ description.privateTap = YES;
133
+ description.UUID = uuid;
134
+ AudioObjectID tapID = kAudioObjectUnknown;
135
+ OSStatus status = AudioHardwareCreateProcessTap(description, &tapID);
136
+ if (status != noErr) {
137
+ throw NSErrorException(MakeStatusError(3, @"AudioHardwareCreateProcessTap failed", status));
138
+ }
139
+ tapID_ = tapID;
140
+ tapUUID_ = uuid;
141
+
142
+ UInt32 formatSize = sizeof(format_);
143
+ AudioObjectPropertyAddress formatAddress = {kAudioTapPropertyFormat, kAudioObjectPropertyScopeGlobal,
144
+ kAudioObjectPropertyElementMain};
145
+ status = AudioObjectGetPropertyData(tapID_, &formatAddress, 0, nullptr, &formatSize, &format_);
146
+ if (status != noErr) {
147
+ TearDown();
148
+ throw NSErrorException(MakeStatusError(4, @"Failed to read the tap's audio format", status));
149
+ }
150
+
151
+ // UID includes the tap's own per-session UUID, not just udid_ — concurrent sessions on the
152
+ // same device are supported, and a udid_-only UID would let two collide on the same
153
+ // persistent UID, which CoreAudio dedupes rather than erroring.
154
+ NSDictionary* aggregateDescription = @{
155
+ @(kAudioAggregateDeviceNameKey) : [NSString stringWithFormat:@"coresim-audio-%@", udid_],
156
+ @(kAudioAggregateDeviceUIDKey) :
157
+ [NSString stringWithFormat:@"com.appium.coresim.audio.%@.%@", udid_, uuid.UUIDString],
158
+ @(kAudioAggregateDeviceIsPrivateKey) : @YES,
159
+ @(kAudioAggregateDeviceTapAutoStartKey) : @NO,
160
+ @(kAudioAggregateDeviceTapListKey) : @[ @{
161
+ @(kAudioSubTapUIDKey) : uuid.UUIDString,
162
+ @(kAudioSubTapDriftCompensationKey) : @YES,
163
+ } ],
164
+ };
165
+ AudioObjectID aggregateID = kAudioObjectUnknown;
166
+ status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef)aggregateDescription, &aggregateID);
167
+ if (status != noErr) {
168
+ TearDown();
169
+ throw NSErrorException(MakeStatusError(5, @"AudioHardwareCreateAggregateDevice failed", status));
170
+ }
171
+ aggregateID_ = aggregateID;
172
+
173
+ running_ = true; // before AudioDeviceStart — the IOProc block can fire before it returns
174
+ AudioDeviceIOProcID procID = nullptr;
175
+ status = AudioDeviceCreateIOProcIDWithBlock(
176
+ &procID, aggregateID_, queue_,
177
+ ^(const AudioTimeStamp*, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime,
178
+ AudioBufferList*, const AudioTimeStamp*) {
179
+ HandleBuffer(inInputData, inInputTime);
180
+ });
181
+ if (status != noErr || procID == nullptr) {
182
+ running_ = false;
183
+ TearDown();
184
+ throw NSErrorException(MakeStatusError(6, @"AudioDeviceCreateIOProcIDWithBlock failed", status));
185
+ }
186
+ procID_ = procID;
187
+
188
+ status = AudioDeviceStart(aggregateID_, procID_);
189
+ if (status != noErr) {
190
+ running_ = false;
191
+ TearDown();
192
+ throw NSErrorException(MakeStatusError(7, @"AudioDeviceStart failed", status));
193
+ }
194
+
195
+ StartPolling();
196
+ }
197
+
198
+ API_AVAILABLE(macos(14.2))
199
+ void StartPolling() {
200
+ dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue_);
201
+ dispatch_source_set_timer(
202
+ timer, dispatch_time(DISPATCH_TIME_NOW, static_cast<int64_t>(kPollIntervalSeconds * NSEC_PER_SEC)),
203
+ static_cast<uint64_t>(kPollIntervalSeconds * NSEC_PER_SEC),
204
+ static_cast<uint64_t>(kPollIntervalSeconds * NSEC_PER_SEC / 10));
205
+ // `this` outlives the timer: Stop() always drains or outruns it before `this` can be
206
+ // destroyed (see its comment).
207
+ dispatch_source_set_event_handler(timer, ^{
208
+ PollAndRefresh();
209
+ });
210
+ pollTimer_ = timer;
211
+ dispatch_resume(pollTimer_);
212
+ }
213
+
214
+ // Refreshes the tapped process set in place via kAudioTapPropertyDescription — no tap/aggregate
215
+ // recreation needed, confirmed by that property's own header comment ("can be used to modify
216
+ // and set the description of an existing tap").
217
+ API_AVAILABLE(macos(14.2))
218
+ void PollAndRefresh() {
219
+ if (!running_) {
220
+ return;
221
+ }
222
+ @autoreleasepool {
223
+ std::vector<AudioObjectID> current = ResolveProcessObjects(udid_);
224
+ if (current.empty()) {
225
+ // The device's guest process tree vanished entirely (shut down/erased mid-capture) — a
226
+ // real, reportable failure, not a transient empty tick.
227
+ if (onError_) {
228
+ onError_(MakeError(8, [NSString stringWithFormat:@"Device '%@' no longer has any audio-capturable guest "
229
+ @"processes — was it shut down?",
230
+ udid_]));
231
+ }
232
+ StopFromQueue();
233
+ return;
234
+ }
235
+ if (current == lastProcessSet_) {
236
+ return;
237
+ }
238
+ CATapDescription* description = [[CATapDescription alloc] initStereoMixdownOfProcesses:ToNumberArray(current)];
239
+ description.privateTap = YES;
240
+ description.UUID = tapUUID_;
241
+ AudioObjectPropertyAddress descAddress = {kAudioTapPropertyDescription, kAudioObjectPropertyScopeGlobal,
242
+ kAudioObjectPropertyElementMain};
243
+ // The property value is the CATapDescription* itself — inData must point to a variable
244
+ // holding that pointer, not be the pointer value cast to an address (which reads the
245
+ // object's own memory as if it were the property buffer).
246
+ void* descriptionPtr = (__bridge void*)description;
247
+ OSStatus status =
248
+ AudioObjectSetPropertyData(tapID_, &descAddress, 0, nullptr, sizeof(descriptionPtr), &descriptionPtr);
249
+ if (status != noErr) {
250
+ // Keep running with the stale set rather than tearing down the whole capture over one
251
+ // failed refresh — the next poll tick retries.
252
+ if (onError_) {
253
+ onError_(MakeStatusError(9, @"Failed to update the audio tap's process list", status));
254
+ }
255
+ return;
256
+ }
257
+ lastProcessSet_ = std::move(current);
258
+ }
259
+ }
260
+
261
+ void HandleBuffer(const AudioBufferList* data, const AudioTimeStamp* time) {
262
+ if (!running_ || !onBuffer_) {
263
+ return;
264
+ }
265
+ try {
266
+ onBuffer_(data, time);
267
+ } catch (const std::exception& e) {
268
+ // onBuffer_ (e.g. AudioEncoder::EncodePCM) can throw — uncaught, it'd crash the process.
269
+ // Routed through the same onError_/StopFromQueue() path PollAndRefresh's own errors use.
270
+ if (onError_) {
271
+ onError_(MakeError(10, [NSString stringWithFormat:@"Audio buffer consumer failed: %s", e.what()]));
272
+ }
273
+ StopFromQueue();
274
+ }
275
+ }
276
+
277
+ // Same as Stop() minus the dispatch_sync barrier — only safe from within a callback already
278
+ // serialized on `queue_` (PollAndRefresh); would deadlock waiting on itself otherwise.
279
+ void StopFromQueue() {
280
+ if (!running_.exchange(false)) {
281
+ return; // idempotent — e.g. an external Stop() already won this race
282
+ }
283
+ if (pollTimer_ != nullptr) {
284
+ dispatch_source_cancel(pollTimer_);
285
+ pollTimer_ = nullptr;
286
+ }
287
+ TearDown();
288
+ if (onEnd_) {
289
+ onEnd_();
290
+ }
291
+ }
292
+
293
+ // Deliberately not API_AVAILABLE-annotated so Stop()/StopFromQueue()/~Impl() can call it
294
+ // unconditionally without themselves needing an availability guard — the guarded body below is
295
+ // a no-op whenever nothing was actually created (Start() never got past the macOS-version gate).
296
+ void TearDown() {
297
+ if (aggregateID_ == kAudioObjectUnknown && tapID_ == kAudioObjectUnknown) {
298
+ return;
299
+ }
300
+ if (@available(macOS 14.2, *)) {
301
+ if (aggregateID_ != kAudioObjectUnknown) {
302
+ if (procID_ != nullptr) {
303
+ AudioDeviceStop(aggregateID_, procID_);
304
+ AudioDeviceDestroyIOProcID(aggregateID_, procID_);
305
+ procID_ = nullptr;
306
+ }
307
+ // Destruction is documented as asynchronous — may complete after this call returns — so
308
+ // this UID isn't assumed immediately reusable.
309
+ AudioHardwareDestroyAggregateDevice(aggregateID_);
310
+ aggregateID_ = kAudioObjectUnknown;
311
+ }
312
+ if (tapID_ != kAudioObjectUnknown) {
313
+ AudioHardwareDestroyProcessTap(tapID_);
314
+ tapID_ = kAudioObjectUnknown;
315
+ }
316
+ }
317
+ }
318
+
319
+ static constexpr double kPollIntervalSeconds = 1.0;
320
+
321
+ NSString* udid_;
322
+ std::function<void(const AudioBufferList*, const AudioTimeStamp*)> onBuffer_;
323
+ std::function<void(NSError*)> onError_;
324
+ std::function<void()> onEnd_;
325
+
326
+ dispatch_queue_t queue_ = nullptr;
327
+ dispatch_source_t pollTimer_ = nullptr;
328
+ AudioObjectID tapID_ = kAudioObjectUnknown;
329
+ AudioObjectID aggregateID_ = kAudioObjectUnknown;
330
+ AudioDeviceIOProcID procID_ = nullptr;
331
+ NSUUID* tapUUID_ = nil;
332
+ AudioStreamBasicDescription format_ = {};
333
+ std::vector<AudioObjectID> lastProcessSet_;
334
+ std::atomic<bool> running_{false};
335
+ };
336
+
337
+ AudioTapSession::AudioTapSession(NSString* udid,
338
+ std::function<void(const AudioBufferList*, const AudioTimeStamp*)> onBuffer,
339
+ std::function<void(NSError*)> onError, std::function<void()> onEnd)
340
+ : impl_(std::make_unique<Impl>(udid, std::move(onBuffer), std::move(onError), std::move(onEnd))) {}
341
+
342
+ AudioTapSession::~AudioTapSession() = default;
343
+
344
+ void AudioTapSession::Start() { impl_->Start(); }
345
+
346
+ void AudioTapSession::Stop() { impl_->Stop(); }
347
+
348
+ const AudioStreamBasicDescription& AudioTapSession::Format() const { return impl_->Format(); }
349
+
350
+ } // namespace coresim
@@ -2,6 +2,10 @@
2
2
 
3
3
  #import <Foundation/Foundation.h>
4
4
 
5
+ #include <sys/types.h>
6
+
7
+ #include <vector>
8
+
5
9
  namespace coresim {
6
10
 
7
11
  // Locates the Unix-domain socket the device's `com.apple.webinspectord_sim` launchd job listens
@@ -10,4 +14,10 @@ namespace coresim {
10
14
  // the same mechanism `lsof -aUc launchd_sim` uses.
11
15
  NSString* FindWebInspectorSocket(NSString* udid, NSError** error);
12
16
 
17
+ // Every host process whose environment carries SIMULATOR_UDID=<udid> — that device's SpringBoard
18
+ // plus any app currently launched inside it, all inherited from that device's own launchd_sim
19
+ // (see CLAUDE.md). No CoreSimulator dispatch — same libproc/sysctl introspection as
20
+ // FindWebInspectorSocket. Used to scope a Core Audio process tap to one simulator (sim_audio_tap.h).
21
+ std::vector<pid_t> FindGuestProcessPids(NSString* udid);
22
+
13
23
  } // namespace coresim