@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.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -0
- package/binding.gyp +7 -0
- package/lib/src/commands/video-recording.d.ts +26 -10
- package/lib/src/commands/video-recording.d.ts.map +1 -1
- package/lib/src/commands/video-recording.js +55 -16
- package/lib/src/commands/video-recording.js.map +1 -1
- package/lib/src/commands/video-stream.d.ts +55 -7
- package/lib/src/commands/video-stream.d.ts.map +1 -1
- package/lib/src/commands/video-stream.js +37 -35
- package/lib/src/commands/video-stream.js.map +1 -1
- package/lib/src/index.d.ts +1 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.js +12 -1
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +82 -10
- package/lib/src/types.d.ts.map +1 -1
- package/lib/src/utils/index.d.ts +1 -1
- package/lib/src/utils/index.d.ts.map +1 -1
- package/lib/src/utils/index.js +1 -1
- package/lib/src/utils/index.js.map +1 -1
- package/lib/src/utils/run-catching.d.ts +4 -0
- package/lib/src/utils/run-catching.d.ts.map +1 -1
- package/lib/src/utils/run-catching.js +11 -0
- package/lib/src/utils/run-catching.js.map +1 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/video-recording.ts +75 -25
- package/src/commands/video-stream.ts +48 -39
- package/src/coresim.mm +547 -118
- package/src/index.ts +1 -0
- package/src/native/audio_encoder.h +65 -0
- package/src/native/audio_encoder.mm +270 -0
- package/src/native/av_recording.h +52 -0
- package/src/native/av_recording.mm +424 -0
- package/src/native/av_stream.h +70 -0
- package/src/native/av_stream.mm +217 -0
- package/src/native/monotonic_clock.h +20 -0
- package/src/native/sim_audio_tap.h +67 -0
- package/src/native/sim_audio_tap.mm +350 -0
- package/src/native/sim_process.h +10 -0
- package/src/native/sim_process.mm +81 -18
- package/src/native/sim_video_stream.h +14 -11
- package/src/native/sim_video_stream.mm +27 -383
- package/src/native/video_encoder.h +76 -0
- package/src/native/video_encoder.mm +417 -0
- package/src/native-simctl.ts +12 -1
- package/src/types.ts +95 -11
- package/src/utils/index.ts +1 -1
- 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
|
package/src/native/sim_process.h
CHANGED
|
@@ -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
|