@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.
@@ -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 = @"com.appium.coresim.Pasteboard";
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("com.appium.coresim.pasteboard.pull", DISPATCH_QUEUE_SERIAL);
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("com.appium.coresim.pasteboard.push", DISPATCH_QUEUE_SERIAL);
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 = @"com.appium.coresim.Process";
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 = @"com.appium.coresim.Screenshot";
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
- NSString* uti = format == ScreenshotFormat::kJPEG ? kJPEGUTI : kPNGUTI;
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 = @"com.appium.coresim.VideoRecording";
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 = @"com.appium.coresim.TCCPrivacy";
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 = 15.0;
19
- int bitrate = 2000000;
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 = @"com.appium.coresim.VideoEncoder";
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("com.appium.coresim.videoEncoder", DISPATCH_QUEUE_SERIAL);
120
+ queue_ = dispatch_queue_create("io.appium.coresim.videoEncoder", DISPATCH_QUEUE_SERIAL);
121
121
  }
122
122
 
123
123
  ~Impl() { Stop(); }
@@ -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 15.
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 2,000,000 (2 Mbps). */
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