@appium/coresim 1.7.0 → 1.8.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 +6 -0
- package/README.md +5 -3
- package/binding.gyp +2 -1
- package/lib/src/commands/jpeg-stream.d.ts +44 -0
- package/lib/src/commands/jpeg-stream.d.ts.map +1 -0
- package/lib/src/commands/jpeg-stream.js +198 -0
- package/lib/src/commands/jpeg-stream.js.map +1 -0
- package/lib/src/index.d.ts +2 -1
- package/lib/src/index.d.ts.map +1 -1
- package/lib/src/index.js +1 -0
- package/lib/src/index.js.map +1 -1
- package/lib/src/native-simctl.d.ts +2 -0
- package/lib/src/native-simctl.d.ts.map +1 -1
- package/lib/src/native-simctl.js +3 -0
- package/lib/src/native-simctl.js.map +1 -1
- package/lib/src/types.d.ts +55 -2
- package/lib/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/@appium+coresim.node +0 -0
- package/src/commands/jpeg-stream.ts +233 -0
- package/src/coresim.mm +170 -6
- package/src/index.ts +3 -0
- package/src/native/audio_encoder.mm +1 -1
- package/src/native/av_recording.mm +1 -1
- package/src/native/sim_audio_tap.mm +3 -3
- package/src/native/sim_jpeg_stream.h +69 -0
- package/src/native/sim_jpeg_stream.mm +322 -0
- package/src/native/sim_pasteboard.mm +3 -3
- package/src/native/sim_process.mm +1 -1
- package/src/native/sim_screenshot.h +8 -0
- package/src/native/sim_screenshot.mm +29 -25
- package/src/native/sim_video_recording.mm +1 -1
- package/src/native/tcc_privacy.mm +1 -1
- package/src/native/video_encoder.h +2 -2
- package/src/native/video_encoder.mm +2 -2
- package/src/native-simctl.ts +3 -0
- package/src/types.ts +59 -2
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#include "sim_jpeg_stream.h"
|
|
2
|
+
|
|
3
|
+
#import <CoreImage/CoreImage.h>
|
|
4
|
+
#import <IOSurface/IOSurface.h>
|
|
5
|
+
|
|
6
|
+
#include <algorithm>
|
|
7
|
+
#include <atomic>
|
|
8
|
+
#include <cmath>
|
|
9
|
+
|
|
10
|
+
#include "monotonic_clock.h"
|
|
11
|
+
#include "nserror_bridge.h"
|
|
12
|
+
#include "sim_screenshot.h"
|
|
13
|
+
|
|
14
|
+
namespace coresim {
|
|
15
|
+
|
|
16
|
+
namespace {
|
|
17
|
+
|
|
18
|
+
NSString* const kJpegStreamErrorDomain = @"io.appium.coresim.JpegStream";
|
|
19
|
+
|
|
20
|
+
// A scale this close to 1.0 (from a 1-100 percent option divided by 100.0 — never exactly 1.0
|
|
21
|
+
// except at 100%) is treated as "no scaling" — comparing doubles for exact equality is unreliable.
|
|
22
|
+
constexpr double kScaleEpsilon = 1e-9;
|
|
23
|
+
|
|
24
|
+
// If no frame can be produced for this long — the display surface staying unavailable, or the
|
|
25
|
+
// CIImage/CGImage render failing — for that whole stretch, something is genuinely wrong (a
|
|
26
|
+
// permanently dropped connection, say) rather than a one-off transient hiccup; escalate to a real
|
|
27
|
+
// error instead of polling forever with nothing to show for it and no error ever reported.
|
|
28
|
+
constexpr double kMaxStallSeconds = 10.0;
|
|
29
|
+
|
|
30
|
+
NSError* MakeError(NSInteger code, NSString* message) {
|
|
31
|
+
return [NSError errorWithDomain:kJpegStreamErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
} // namespace
|
|
35
|
+
|
|
36
|
+
class JpegStreamSession::Impl {
|
|
37
|
+
public:
|
|
38
|
+
Impl(id device, JpegStreamOptions options, std::function<void(JpegFrame)> onFrame,
|
|
39
|
+
std::function<void(NSError*)> onError, std::function<void()> onEnd, std::function<void()> onAbortDelivery)
|
|
40
|
+
: device_(device),
|
|
41
|
+
options_(options),
|
|
42
|
+
onFrame_(std::move(onFrame)),
|
|
43
|
+
onError_(std::move(onError)),
|
|
44
|
+
onEnd_(std::move(onEnd)),
|
|
45
|
+
onAbortDelivery_(std::move(onAbortDelivery)) {
|
|
46
|
+
queue_ = dispatch_queue_create("io.appium.coresim.jpegStream", DISPATCH_QUEUE_SERIAL);
|
|
47
|
+
// Persistent, unlike CaptureScreenshot's own one-shot CIContext (see sim_screenshot.mm) — that
|
|
48
|
+
// file's comment calls this out as exactly the tradeoff a continuous streaming path should
|
|
49
|
+
// make instead.
|
|
50
|
+
context_ = [CIContext contextWithOptions:nil];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
~Impl() { Stop(); }
|
|
54
|
+
|
|
55
|
+
void Start() {
|
|
56
|
+
NSError* error = nil;
|
|
57
|
+
id descriptor = ResolveCaptureDisplay(device_, options_.displayId, &error);
|
|
58
|
+
if (descriptor == nil) {
|
|
59
|
+
throw NSErrorException(error);
|
|
60
|
+
}
|
|
61
|
+
id surfaceObj = CurrentDisplaySurface(descriptor);
|
|
62
|
+
if (surfaceObj == nil) {
|
|
63
|
+
throw NSErrorException(MakeError(1, @"The device's display surface is not available yet"));
|
|
64
|
+
}
|
|
65
|
+
IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
|
|
66
|
+
|
|
67
|
+
startTime_ = MonotonicSeconds();
|
|
68
|
+
// Set before the initial encode below — EmitFrame measures elapsed time from it, and running_
|
|
69
|
+
// gates whether a frame is delivered at all (see Tick()'s own check).
|
|
70
|
+
running_ = true;
|
|
71
|
+
// Encode immediately rather than waiting for a *changed* seed on the first tick, or the stream
|
|
72
|
+
// would stay silent until the display changes again — unlike Tick(), this doesn't check
|
|
73
|
+
// lastSeed_ first (it's still its default 0), so EncodeCurrentSeedAndEmit always runs once here.
|
|
74
|
+
NSError* encodeError = nil;
|
|
75
|
+
EncodeCurrentSeedAndEmit(surface, &encodeError);
|
|
76
|
+
if (encodeError != nil) {
|
|
77
|
+
running_ = false;
|
|
78
|
+
throw NSErrorException(encodeError);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
double interval = 1.0 / std::max(options_.fps, 1.0);
|
|
82
|
+
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue_);
|
|
83
|
+
dispatch_source_set_timer(timer, dispatch_time(DISPATCH_TIME_NOW, 0),
|
|
84
|
+
static_cast<uint64_t>(interval * NSEC_PER_SEC),
|
|
85
|
+
static_cast<uint64_t>(interval * NSEC_PER_SEC / 10));
|
|
86
|
+
// `this` outlives the timer: Stop()/StopFromQueue() always cancel or outrun it before `this`
|
|
87
|
+
// can be destroyed (see their comments below).
|
|
88
|
+
dispatch_source_set_event_handler(timer, ^{
|
|
89
|
+
Tick();
|
|
90
|
+
});
|
|
91
|
+
timer_ = timer;
|
|
92
|
+
dispatch_resume(timer_);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Callable from any thread except `queue_` itself (would deadlock on the dispatch_sync below).
|
|
96
|
+
void Stop() {
|
|
97
|
+
if (!running_.exchange(false)) {
|
|
98
|
+
return; // idempotent
|
|
99
|
+
}
|
|
100
|
+
if (timer_ != nullptr) {
|
|
101
|
+
dispatch_source_cancel(timer_);
|
|
102
|
+
// dispatch_source_cancel doesn't preempt a currently-executing handler — an already-running
|
|
103
|
+
// Tick() (past its own `running_` check) can still run to completion and emit one last frame
|
|
104
|
+
// via onFrame_ before this returns. This blocks until that happens, so onEnd_ below (which
|
|
105
|
+
// the caller uses to release resources onFrame_ needs, e.g. a ThreadSafeFunction) never races
|
|
106
|
+
// a still-in-flight emit — not, as such, a guarantee that no further frame is ever emitted.
|
|
107
|
+
dispatch_sync(queue_, ^{
|
|
108
|
+
});
|
|
109
|
+
timer_ = nullptr;
|
|
110
|
+
}
|
|
111
|
+
if (onEnd_) {
|
|
112
|
+
onEnd_();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
void AbortDelivery() {
|
|
117
|
+
if (onAbortDelivery_) {
|
|
118
|
+
onAbortDelivery_();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private:
|
|
123
|
+
// Same as Stop() minus the dispatch_sync barrier — only safe from within Tick() itself, already
|
|
124
|
+
// serialized on `queue_`; would race a concurrent Tick() from any other thread.
|
|
125
|
+
void StopFromQueue() {
|
|
126
|
+
if (!running_.exchange(false)) {
|
|
127
|
+
return; // idempotent — e.g. an external Stop() already won this race
|
|
128
|
+
}
|
|
129
|
+
if (timer_ != nullptr) {
|
|
130
|
+
dispatch_source_cancel(timer_);
|
|
131
|
+
timer_ = nullptr;
|
|
132
|
+
}
|
|
133
|
+
if (onEnd_) {
|
|
134
|
+
onEnd_();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
void Tick() {
|
|
139
|
+
if (!running_) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
@autoreleasepool {
|
|
143
|
+
try {
|
|
144
|
+
// Re-resolved every tick (like CaptureScreenshot does), not cached once in Start(), so a
|
|
145
|
+
// deleted device or disconnected display surfaces a real error instead of Tick() quietly
|
|
146
|
+
// doing nothing forever.
|
|
147
|
+
NSError* resolveError = nil;
|
|
148
|
+
id descriptor = ResolveCaptureDisplay(device_, options_.displayId, &resolveError);
|
|
149
|
+
if (descriptor == nil) {
|
|
150
|
+
if (onError_) {
|
|
151
|
+
onError_(resolveError);
|
|
152
|
+
}
|
|
153
|
+
StopFromQueue();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
id surfaceObj = CurrentDisplaySurface(descriptor);
|
|
157
|
+
if (surfaceObj == nil) {
|
|
158
|
+
ReportIfStalledTooLong(); // transient — the connection may not have a frame ready yet
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
IOSurfaceRef surface = (__bridge IOSurfaceRef)surfaceObj;
|
|
162
|
+
uint32_t seed = IOSurfaceGetSeed(surface);
|
|
163
|
+
if (seed == lastSeed_) {
|
|
164
|
+
// Unchanged since the last tick (mirrors VideoFrameEncoder's own seed check) — the
|
|
165
|
+
// surface itself is fine, just nothing new to encode, so this resolves any stall.
|
|
166
|
+
stalledSince_ = 0;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
NSError* encodeError = nil;
|
|
170
|
+
bool encoded = EncodeCurrentSeedAndEmit(surface, &encodeError);
|
|
171
|
+
if (encodeError != nil) {
|
|
172
|
+
if (onError_) {
|
|
173
|
+
onError_(encodeError);
|
|
174
|
+
}
|
|
175
|
+
StopFromQueue();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (encoded) {
|
|
179
|
+
stalledSince_ = 0;
|
|
180
|
+
} else {
|
|
181
|
+
ReportIfStalledTooLong(); // transient — e.g. a momentary CIImage/CGImage render failure
|
|
182
|
+
}
|
|
183
|
+
} catch (const std::exception& e) {
|
|
184
|
+
// Without this, an exception here (e.g. a dropped display-proxy connection, which surfaces
|
|
185
|
+
// as NativeSimUnavailableError/ObjCException — both std::exception subtypes — via
|
|
186
|
+
// ResolveCaptureDisplay's own dynamic dispatch) would escape this bare GCD timer handler
|
|
187
|
+
// uncaught and crash the whole process (see CLAUDE.md; mirrors VideoFrameEncoder::Tick()).
|
|
188
|
+
if (onError_) {
|
|
189
|
+
onError_(MakeError(4, [NSString stringWithFormat:@"JPEG streaming failed: %s", e.what()]));
|
|
190
|
+
}
|
|
191
|
+
StopFromQueue();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Called from Tick() on a tick that produced nothing but also wasn't a genuine (reported) error —
|
|
197
|
+
// starts a stall timer on the first such tick, and escalates to a real onError_/StopFromQueue()
|
|
198
|
+
// once it's run past kMaxStallSeconds without a single successful tick (a frame, or an unchanged-
|
|
199
|
+
// seed check) in between. Without this, a display surface that never comes back (or a
|
|
200
|
+
// CIImage/CGImage render that never succeeds again) would poll forever with nothing to show for
|
|
201
|
+
// it and no error ever reported (see CLAUDE.md).
|
|
202
|
+
void ReportIfStalledTooLong() {
|
|
203
|
+
double now = MonotonicSeconds();
|
|
204
|
+
if (stalledSince_ == 0) {
|
|
205
|
+
stalledSince_ = now;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (now - stalledSince_ > kMaxStallSeconds) {
|
|
209
|
+
if (onError_) {
|
|
210
|
+
onError_(MakeError(5, @"No JPEG frame could be produced for too long"));
|
|
211
|
+
}
|
|
212
|
+
StopFromQueue();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Reads `surface`'s current seed, attempts one encode, and — only on success — commits exactly
|
|
217
|
+
// that pre-encode seed to lastSeed_ and emits the frame. Reading the seed before encoding (never
|
|
218
|
+
// a value re-read afterward) matters: if the display changes again while EncodeSurface() is still
|
|
219
|
+
// running, the pre-encode seed still correctly identifies which content this frame captures, so
|
|
220
|
+
// the next caller sees the surface's now-newer seed as "changed" and retries — reading it after
|
|
221
|
+
// encoding instead would wrongly commit the *newer* seed against the *older* frame just emitted,
|
|
222
|
+
// permanently losing that update (the following unchanged-seed check would then treat it as
|
|
223
|
+
// already delivered, silently, since it looks identical to a genuinely static display). Shared by
|
|
224
|
+
// Start() (always called once, regardless of lastSeed_) and Tick() (only once a changed seed was
|
|
225
|
+
// already observed) so this ordering can't independently drift between the two again.
|
|
226
|
+
//
|
|
227
|
+
// Returns whether a frame was produced. Sets *error only for a genuine encode failure that
|
|
228
|
+
// should end the whole session; a false return with *error left nil means "transient, retry
|
|
229
|
+
// next tick" (mirrors VideoFrameEncoder::EncodeSurface's identical CVPixelBufferCreateWithIOSurface
|
|
230
|
+
// transient-failure contract in video_encoder.mm) — lastSeed_ is deliberately left stale then, so
|
|
231
|
+
// the next attempt retries this same content instead of silently skipping it forever.
|
|
232
|
+
bool EncodeCurrentSeedAndEmit(IOSurfaceRef surface, NSError** error) {
|
|
233
|
+
uint32_t seed = IOSurfaceGetSeed(surface);
|
|
234
|
+
NSData* data = nil;
|
|
235
|
+
bool encoded = EncodeSurface(surface, &data, error);
|
|
236
|
+
if (encoded) {
|
|
237
|
+
lastSeed_ = seed;
|
|
238
|
+
EmitFrame(data);
|
|
239
|
+
}
|
|
240
|
+
return encoded;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Returns whether a frame was produced. Sets *error only for a genuine encode failure that
|
|
244
|
+
// should end the whole session; a false return with *error left nil means "transient, retry
|
|
245
|
+
// next tick" (mirrors VideoFrameEncoder::EncodeSurface's identical CVPixelBufferCreateWithIOSurface
|
|
246
|
+
// transient-failure contract in video_encoder.mm).
|
|
247
|
+
bool EncodeSurface(IOSurfaceRef surface, NSData** outData, NSError** error) {
|
|
248
|
+
CIImage* ciImage = [CIImage imageWithIOSurface:surface];
|
|
249
|
+
if (ciImage == nil) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
// Scaling the CIImage before rendering (rather than resizing an already-encoded JPEG
|
|
253
|
+
// afterward) means the CGImage/JPEG below is produced at the target resolution directly — no
|
|
254
|
+
// extra decode/resize/re-encode round trip.
|
|
255
|
+
if (std::fabs(options_.scale - 1.0) > kScaleEpsilon) {
|
|
256
|
+
ciImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeScale(options_.scale, options_.scale)];
|
|
257
|
+
}
|
|
258
|
+
CGImageRef cgImage = [context_ createCGImage:ciImage fromRect:ciImage.extent];
|
|
259
|
+
if (cgImage == nil) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
// Same CGImageDestination-based encode CaptureScreenshot's kJPEG format uses (sim_screenshot.mm).
|
|
263
|
+
NSData* imageData = EncodeImage(cgImage, ScreenshotFormat::kJPEG, options_.jpegQualityPercent, error);
|
|
264
|
+
CGImageRelease(cgImage);
|
|
265
|
+
if (imageData == nil) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
*outData = imageData;
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Called either from Start() (on whichever thread calls it, before the timer/queue_ even starts
|
|
273
|
+
// running) or from Tick() (already serialized on queue_, once the timer is live) — never both at
|
|
274
|
+
// once, since Start() always finishes (and only then resumes the timer) before Tick() can fire.
|
|
275
|
+
// So sequence_ needs no synchronization, unlike VideoStreamSession's (whose encoder callback can
|
|
276
|
+
// run concurrently with the poll loop on a different thread).
|
|
277
|
+
void EmitFrame(NSData* data) {
|
|
278
|
+
JpegFrame frame;
|
|
279
|
+
frame.sequence = sequence_++;
|
|
280
|
+
frame.timestampMicros = static_cast<int64_t>((MonotonicSeconds() - startTime_) * 1000000);
|
|
281
|
+
const uint8_t* bytes = static_cast<const uint8_t*>(data.bytes);
|
|
282
|
+
frame.data.assign(bytes, bytes + data.length);
|
|
283
|
+
if (onFrame_) {
|
|
284
|
+
onFrame_(std::move(frame));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
id device_;
|
|
289
|
+
JpegStreamOptions options_;
|
|
290
|
+
std::function<void(JpegFrame)> onFrame_;
|
|
291
|
+
std::function<void(NSError*)> onError_;
|
|
292
|
+
std::function<void()> onEnd_;
|
|
293
|
+
std::function<void()> onAbortDelivery_;
|
|
294
|
+
|
|
295
|
+
dispatch_queue_t queue_ = nullptr;
|
|
296
|
+
dispatch_source_t timer_ = nullptr;
|
|
297
|
+
CIContext* context_ = nil;
|
|
298
|
+
uint32_t lastSeed_ = 0;
|
|
299
|
+
double startTime_ = 0;
|
|
300
|
+
uint64_t sequence_ = 0;
|
|
301
|
+
// 0 means "no stall in progress" — set to MonotonicSeconds() by ReportIfStalledTooLong() on the
|
|
302
|
+
// first unproductive tick of a run, cleared back to 0 by any tick that makes real progress
|
|
303
|
+
// (a produced frame, or an unchanged-seed check confirming the surface itself is still fine).
|
|
304
|
+
double stalledSince_ = 0;
|
|
305
|
+
std::atomic<bool> running_{false};
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
JpegStreamSession::JpegStreamSession(id device, JpegStreamOptions options, std::function<void(JpegFrame)> onFrame,
|
|
309
|
+
std::function<void(NSError*)> onError, std::function<void()> onEnd,
|
|
310
|
+
std::function<void()> onAbortDelivery)
|
|
311
|
+
: impl_(std::make_unique<Impl>(device, options, std::move(onFrame), std::move(onError), std::move(onEnd),
|
|
312
|
+
std::move(onAbortDelivery))) {}
|
|
313
|
+
|
|
314
|
+
JpegStreamSession::~JpegStreamSession() = default;
|
|
315
|
+
|
|
316
|
+
void JpegStreamSession::Start() { impl_->Start(); }
|
|
317
|
+
|
|
318
|
+
void JpegStreamSession::Stop() { impl_->Stop(); }
|
|
319
|
+
|
|
320
|
+
void JpegStreamSession::AbortDelivery() { impl_->AbortDelivery(); }
|
|
321
|
+
|
|
322
|
+
} // namespace coresim
|
|
@@ -69,7 +69,7 @@ namespace coresim {
|
|
|
69
69
|
|
|
70
70
|
namespace {
|
|
71
71
|
|
|
72
|
-
NSString* const kPasteboardErrorDomain = @"
|
|
72
|
+
NSString* const kPasteboardErrorDomain = @"io.appium.coresim.Pasteboard";
|
|
73
73
|
|
|
74
74
|
// The UTI both pasteboard mechanisms below exchange plain text under.
|
|
75
75
|
NSString* const kPlainTextUTI = @"public.utf8-plain-text";
|
|
@@ -166,7 +166,7 @@ NSString* PullPasteboardStringModern(id device, NSError** error) {
|
|
|
166
166
|
CoresimPasteboardDelegate* delegate = [CoresimPasteboardDelegate new];
|
|
167
167
|
delegate.activeSema = dispatch_semaphore_create(0);
|
|
168
168
|
delegate.appliedSema = dispatch_semaphore_create(0);
|
|
169
|
-
dispatch_queue_t queue = dispatch_queue_create("
|
|
169
|
+
dispatch_queue_t queue = dispatch_queue_create("io.appium.coresim.pasteboard.pull", DISPATCH_QUEUE_SERIAL);
|
|
170
170
|
|
|
171
171
|
id interface = ConnectPasteboardInterface(device, pasteboard, delegate, queue);
|
|
172
172
|
SafeInvoke([&] {
|
|
@@ -200,7 +200,7 @@ BOOL PushPasteboardStringModern(id device, NSString* content, NSError** error) {
|
|
|
200
200
|
CoresimPasteboardDelegate* delegate = [CoresimPasteboardDelegate new];
|
|
201
201
|
delegate.activeSema = dispatch_semaphore_create(0);
|
|
202
202
|
delegate.appliedSema = dispatch_semaphore_create(0);
|
|
203
|
-
dispatch_queue_t queue = dispatch_queue_create("
|
|
203
|
+
dispatch_queue_t queue = dispatch_queue_create("io.appium.coresim.pasteboard.push", DISPATCH_QUEUE_SERIAL);
|
|
204
204
|
|
|
205
205
|
id interface = ConnectPasteboardInterface(device, pasteboard, delegate, queue);
|
|
206
206
|
SafeInvoke([&] {
|
|
@@ -13,7 +13,7 @@ namespace coresim {
|
|
|
13
13
|
|
|
14
14
|
namespace {
|
|
15
15
|
|
|
16
|
-
NSString* const kProcessErrorDomain = @"
|
|
16
|
+
NSString* const kProcessErrorDomain = @"io.appium.coresim.Process";
|
|
17
17
|
NSString* const kWebInspectorSocketSuffix = @"com.apple.webinspectord_sim.socket";
|
|
18
18
|
|
|
19
19
|
NSError* MakeError(NSInteger code, NSString* message) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#pragma once
|
|
2
2
|
|
|
3
|
+
#import <CoreGraphics/CoreGraphics.h>
|
|
3
4
|
#import <Foundation/Foundation.h>
|
|
4
5
|
|
|
5
6
|
namespace coresim {
|
|
@@ -24,6 +25,13 @@ id CurrentDisplaySurface(id descriptor);
|
|
|
24
25
|
|
|
25
26
|
enum class ScreenshotFormat { kPNG, kJPEG };
|
|
26
27
|
|
|
28
|
+
// Encodes `cgImage` via ImageIO into `format` — the same CGImageDestination-based encode
|
|
29
|
+
// CaptureScreenshot uses for its own final step, exposed for a caller that already has a
|
|
30
|
+
// CGImageRef from its own CIContext (e.g. JpegStreamSession's persistent one — see
|
|
31
|
+
// sim_jpeg_stream.mm). `jpegQualityPercent` semantics match CaptureScreenshot's own parameter of
|
|
32
|
+
// the same name. Returns nil (and sets *error) on encode failure. Does not release `cgImage`.
|
|
33
|
+
NSData* EncodeImage(CGImageRef cgImage, ScreenshotFormat format, NSNumber* jpegQualityPercent, NSError** error);
|
|
34
|
+
|
|
27
35
|
// Captures a display as an image, reading the same in-process framebuffer surface `simctl io
|
|
28
36
|
// <udid> screenshot` itself reads (see sim_screenshot.mm) — no temp file, no subprocess.
|
|
29
37
|
// `displayId`, if non-nil, selects a specific port by its UUID (see ListDisplays); nil selects the
|
|
@@ -12,7 +12,7 @@ namespace coresim {
|
|
|
12
12
|
|
|
13
13
|
namespace {
|
|
14
14
|
|
|
15
|
-
NSString* const kScreenshotErrorDomain = @"
|
|
15
|
+
NSString* const kScreenshotErrorDomain = @"io.appium.coresim.Screenshot";
|
|
16
16
|
|
|
17
17
|
NSError* MakeError(NSInteger code, NSString* message) {
|
|
18
18
|
return [NSError errorWithDomain:kScreenshotErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -171,6 +171,32 @@ NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
|
|
|
171
171
|
return result;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
NSData* EncodeImage(CGImageRef cgImage, ScreenshotFormat format, NSNumber* jpegQualityPercent, NSError** error) {
|
|
175
|
+
NSString* uti = format == ScreenshotFormat::kJPEG ? kJPEGUTI : kPNGUTI;
|
|
176
|
+
NSMutableData* imageData = [NSMutableData data];
|
|
177
|
+
CGImageDestinationRef destination =
|
|
178
|
+
CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, (__bridge CFStringRef)uti, 1, NULL);
|
|
179
|
+
if (destination == nullptr) {
|
|
180
|
+
*error = MakeError(7, @"Failed to create an image encoder");
|
|
181
|
+
return nil;
|
|
182
|
+
}
|
|
183
|
+
// kCGImageDestinationLossyCompressionQuality is meaningless for PNG (always lossless) — ImageIO
|
|
184
|
+
// silently ignores properties a format doesn't use, so this is only gated on jpegQualityPercent
|
|
185
|
+
// being present, not on `format` too.
|
|
186
|
+
NSDictionary* properties =
|
|
187
|
+
jpegQualityPercent != nil
|
|
188
|
+
? @{(NSString*)kCGImageDestinationLossyCompressionQuality : @(jpegQualityPercent.doubleValue / 100.0)}
|
|
189
|
+
: nil;
|
|
190
|
+
CGImageDestinationAddImage(destination, cgImage, (__bridge CFDictionaryRef)properties);
|
|
191
|
+
BOOL ok = CGImageDestinationFinalize(destination);
|
|
192
|
+
CFRelease(destination);
|
|
193
|
+
if (!ok) {
|
|
194
|
+
*error = MakeError(8, @"Failed to encode the image");
|
|
195
|
+
return nil;
|
|
196
|
+
}
|
|
197
|
+
return imageData;
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat format, NSNumber* jpegQualityPercent,
|
|
175
201
|
NSError** error) {
|
|
176
202
|
id descriptor = ResolveCaptureDisplay(device, displayId, error);
|
|
@@ -193,7 +219,7 @@ NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat forma
|
|
|
193
219
|
|
|
194
220
|
// A fresh CIContext per call, matching this operation's one-shot semantics (mirrors
|
|
195
221
|
// simctl's own screenshot command) rather than the persistent, reused context a
|
|
196
|
-
// continuous video/streaming path would want.
|
|
222
|
+
// continuous video/streaming path would want (JpegStreamSession — see sim_jpeg_stream.mm).
|
|
197
223
|
CIContext* context = [CIContext contextWithOptions:nil];
|
|
198
224
|
CGImageRef cgImage = [context createCGImage:ciImage fromRect:ciImage.extent];
|
|
199
225
|
if (cgImage == nil) {
|
|
@@ -201,30 +227,8 @@ NSData* CaptureScreenshot(id device, NSString* displayId, ScreenshotFormat forma
|
|
|
201
227
|
return nil;
|
|
202
228
|
}
|
|
203
229
|
|
|
204
|
-
|
|
205
|
-
NSMutableData* imageData = [NSMutableData data];
|
|
206
|
-
CGImageDestinationRef destination =
|
|
207
|
-
CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, (__bridge CFStringRef)uti, 1, NULL);
|
|
208
|
-
if (destination == nullptr) {
|
|
209
|
-
CGImageRelease(cgImage);
|
|
210
|
-
*error = MakeError(7, @"Failed to create an image encoder for the captured screenshot");
|
|
211
|
-
return nil;
|
|
212
|
-
}
|
|
213
|
-
// kCGImageDestinationLossyCompressionQuality is meaningless for PNG (always lossless) — ImageIO
|
|
214
|
-
// silently ignores properties a format doesn't use, so this is only gated on jpegQualityPercent
|
|
215
|
-
// being present, not on `format` too.
|
|
216
|
-
NSDictionary* properties =
|
|
217
|
-
jpegQualityPercent != nil
|
|
218
|
-
? @{(NSString*)kCGImageDestinationLossyCompressionQuality : @(jpegQualityPercent.doubleValue / 100.0)}
|
|
219
|
-
: nil;
|
|
220
|
-
CGImageDestinationAddImage(destination, cgImage, (__bridge CFDictionaryRef)properties);
|
|
221
|
-
BOOL ok = CGImageDestinationFinalize(destination);
|
|
222
|
-
CFRelease(destination);
|
|
230
|
+
NSData* imageData = EncodeImage(cgImage, format, jpegQualityPercent, error);
|
|
223
231
|
CGImageRelease(cgImage);
|
|
224
|
-
if (!ok) {
|
|
225
|
-
*error = MakeError(8, @"Failed to encode the captured screenshot");
|
|
226
|
-
return nil;
|
|
227
|
-
}
|
|
228
232
|
return imageData;
|
|
229
233
|
}
|
|
230
234
|
|
|
@@ -10,7 +10,7 @@ namespace coresim {
|
|
|
10
10
|
|
|
11
11
|
namespace {
|
|
12
12
|
|
|
13
|
-
NSString* const kVideoRecordingErrorDomain = @"
|
|
13
|
+
NSString* const kVideoRecordingErrorDomain = @"io.appium.coresim.VideoRecording";
|
|
14
14
|
|
|
15
15
|
NSError* MakeError(NSInteger code, NSString* message) {
|
|
16
16
|
return [NSError errorWithDomain:kVideoRecordingErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -9,7 +9,7 @@ namespace coresim {
|
|
|
9
9
|
|
|
10
10
|
namespace {
|
|
11
11
|
|
|
12
|
-
NSString* const kTCCErrorDomain = @"
|
|
12
|
+
NSString* const kTCCErrorDomain = @"io.appium.coresim.TCCPrivacy";
|
|
13
13
|
|
|
14
14
|
NSError* MakeError(int code, NSString* message) {
|
|
15
15
|
return [NSError errorWithDomain:kTCCErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -15,8 +15,8 @@ enum class VideoStreamCodec { kH264, kHEVC };
|
|
|
15
15
|
struct VideoEncoderOptions {
|
|
16
16
|
VideoStreamCodec codec = VideoStreamCodec::kH264;
|
|
17
17
|
NSString* displayId = nil;
|
|
18
|
-
double fps =
|
|
19
|
-
int bitrate =
|
|
18
|
+
double fps = 60.0;
|
|
19
|
+
int bitrate = 4000000;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
// Polls the live display IOSurface (sim_screenshot.h) on a serial queue and encodes changed
|
|
@@ -16,7 +16,7 @@ namespace coresim {
|
|
|
16
16
|
|
|
17
17
|
namespace {
|
|
18
18
|
|
|
19
|
-
NSString* const kVideoEncoderErrorDomain = @"
|
|
19
|
+
NSString* const kVideoEncoderErrorDomain = @"io.appium.coresim.VideoEncoder";
|
|
20
20
|
|
|
21
21
|
NSError* MakeError(NSInteger code, NSString* message) {
|
|
22
22
|
return [NSError errorWithDomain:kVideoEncoderErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
@@ -117,7 +117,7 @@ class VideoFrameEncoder::Impl {
|
|
|
117
117
|
onError_(std::move(onError)),
|
|
118
118
|
onEnd_(std::move(onEnd)),
|
|
119
119
|
sharedClockOrigin_(sharedClockOrigin) {
|
|
120
|
-
queue_ = dispatch_queue_create("
|
|
120
|
+
queue_ = dispatch_queue_create("io.appium.coresim.videoEncoder", DISPATCH_QUEUE_SERIAL);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
~Impl() { Stop(); }
|
package/src/native-simctl.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
setDarwinNotificationState,
|
|
21
21
|
} from './commands/darwin-notification.js';
|
|
22
22
|
import {clearLocation, getEnv, openUrl, pushNotification, setLocation} from './commands/interaction.js';
|
|
23
|
+
import {startJpegStream} from './commands/jpeg-stream.js';
|
|
23
24
|
import {addCertificate, addRootCertificate, resetKeychain} from './commands/keychain.js';
|
|
24
25
|
import {
|
|
25
26
|
bootDevice,
|
|
@@ -60,6 +61,7 @@ import './commands/app.js';
|
|
|
60
61
|
import './commands/biometric.js';
|
|
61
62
|
import './commands/darwin-notification.js';
|
|
62
63
|
import './commands/interaction.js';
|
|
64
|
+
import './commands/jpeg-stream.js';
|
|
63
65
|
import './commands/keychain.js';
|
|
64
66
|
import './commands/lifecycle.js';
|
|
65
67
|
import './commands/media.js';
|
|
@@ -256,6 +258,7 @@ Object.assign(NativeSimctl.prototype, {
|
|
|
256
258
|
stopVideoRecording,
|
|
257
259
|
isVideoRecording,
|
|
258
260
|
startVideoStream,
|
|
261
|
+
startJpegStream,
|
|
259
262
|
|
|
260
263
|
// webinspector
|
|
261
264
|
getWebInspectorSocket,
|
package/src/types.ts
CHANGED
|
@@ -191,10 +191,10 @@ export interface VideoStreamOptions {
|
|
|
191
191
|
codec?: 'h264' | 'hevc';
|
|
192
192
|
/**
|
|
193
193
|
* Max frames/sec to poll the framebuffer at — an unchanged frame is never re-encoded, so this
|
|
194
|
-
* is an upper bound, not a guarantee. Must be >= 1. Defaults to
|
|
194
|
+
* is an upper bound, not a guarantee. Must be >= 1. Defaults to 60.
|
|
195
195
|
*/
|
|
196
196
|
fps?: number;
|
|
197
|
-
/** Target average bitrate, in bits/sec. Defaults to
|
|
197
|
+
/** Target average bitrate, in bits/sec. Defaults to 4,000,000 (4 Mbps). */
|
|
198
198
|
bitrate?: number;
|
|
199
199
|
/**
|
|
200
200
|
* Also stream the device's audio, interleaved into the same `accessUnits()` sequence. Defaults
|
|
@@ -204,6 +204,44 @@ export interface VideoStreamOptions {
|
|
|
204
204
|
audio?: boolean;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/** Options for `NativeSimctl.startJpegStream`. */
|
|
208
|
+
export interface JpegStreamOptions {
|
|
209
|
+
/**
|
|
210
|
+
* Which display to stream, by `id` from `getDisplays()`. Defaults to the primary display
|
|
211
|
+
* (falling back to the first renderable display if none is primary, e.g. tvOS).
|
|
212
|
+
*/
|
|
213
|
+
displayId?: string;
|
|
214
|
+
/**
|
|
215
|
+
* Max frames/sec to poll the framebuffer at — an unchanged frame is never re-encoded, so this
|
|
216
|
+
* is an upper bound, not a guarantee. Must be >= 1. Defaults to 60.
|
|
217
|
+
*/
|
|
218
|
+
fps?: number;
|
|
219
|
+
/**
|
|
220
|
+
* JPEG quality as a percentage (0 = smallest/most compressed, 100 = largest/least compressed).
|
|
221
|
+
* Defaults to 80 — noticeably smaller than {@link ScreenshotOptions.quality}'s own (near-
|
|
222
|
+
* lossless) default, more suitable for a continuous live stream than a one-off screenshot.
|
|
223
|
+
*/
|
|
224
|
+
quality?: number;
|
|
225
|
+
/**
|
|
226
|
+
* Frame scale as a percentage of the original display resolution — 100 (default) performs no
|
|
227
|
+
* scaling; must be greater than 0 and no greater than 100.
|
|
228
|
+
*/
|
|
229
|
+
scale?: number;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* One JPEG-encoded frame from `JpegStream.frames()`. Unlike {@link VideoAccessUnit}, every frame
|
|
234
|
+
* is independently decodable — there's no keyframe/interframe distinction — so consumers (e.g. an
|
|
235
|
+
* MJPEG multipart HTTP stream built from this sequence) can start from, or drop, any frame freely.
|
|
236
|
+
*/
|
|
237
|
+
export interface JpegFrame {
|
|
238
|
+
data: Buffer;
|
|
239
|
+
/** Monotonically increasing, starting at 0. */
|
|
240
|
+
sequence: number;
|
|
241
|
+
/** Microseconds since the stream started. */
|
|
242
|
+
timestampMicros: number;
|
|
243
|
+
}
|
|
244
|
+
|
|
207
245
|
/**
|
|
208
246
|
* One encoded unit from `VideoStream.accessUnits()`, discriminated by `track`: a video unit
|
|
209
247
|
* (Annex-B NAL units — a keyframe's `data` has parameter sets, SPS/PPS or VPS/SPS/PPS for HEVC,
|
|
@@ -349,6 +387,20 @@ export interface NativeVideoStreamHandle {
|
|
|
349
387
|
requestKeyFrame(): void;
|
|
350
388
|
}
|
|
351
389
|
|
|
390
|
+
/** Raw shape of a JPEG frame as the native addon delivers it — see {@link JpegFrame}. */
|
|
391
|
+
export interface NativeJpegFrame {
|
|
392
|
+
data: Buffer;
|
|
393
|
+
sequence: number;
|
|
394
|
+
timestampMicros: number;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export type NativeJpegFrameCallback = (frame: NativeJpegFrame) => void;
|
|
398
|
+
|
|
399
|
+
/** A live `coresim::JpegStreamSession`, wrapped by `coresim.mm`'s `NativeJpegStream`. */
|
|
400
|
+
export interface NativeJpegStreamHandle {
|
|
401
|
+
stop(): Promise<void>;
|
|
402
|
+
}
|
|
403
|
+
|
|
352
404
|
/**
|
|
353
405
|
* A live recording, wrapped by `coresim.mm`'s `NativePrivateRecordingHandle` (video only,
|
|
354
406
|
* addressing CoreSimulator's own internally-tracked private recorder) or `NativeAVRecording`
|
|
@@ -431,6 +483,11 @@ export interface NativeDeviceHandle {
|
|
|
431
483
|
onAccessUnit: NativeVideoAccessUnitCallback,
|
|
432
484
|
onError: NativeVideoErrorCallback,
|
|
433
485
|
): Promise<NativeVideoStreamHandle>;
|
|
486
|
+
startJpegStream(
|
|
487
|
+
options: {displayId?: string; fps?: number; quality?: number; scale?: number} | undefined,
|
|
488
|
+
onFrame: NativeJpegFrameCallback,
|
|
489
|
+
onError: NativeVideoErrorCallback,
|
|
490
|
+
): Promise<NativeJpegStreamHandle>;
|
|
434
491
|
spawn(path: string, options: SpawnOptions | undefined, onExit: NativeSpawnExitCallback): Promise<NativeSpawnResult>;
|
|
435
492
|
}
|
|
436
493
|
|