@appium/coresim 1.8.0 → 1.10.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.
@@ -23,14 +23,17 @@ constexpr size_t kMaxPendingAudioSamples = 500; // a few seconds of AAC packets
23
23
 
24
24
  } // namespace
25
25
 
26
- class AVRecordingSession::Impl {
26
+ class AVRecordingSession::Impl : public std::enable_shared_from_this<Impl> {
27
27
  public:
28
28
  Impl(id device, NSString* udid, VideoEncoderOptions videoOptions, NSString* outputFile, bool captureAudio)
29
29
  : device_(device),
30
30
  udid_(udid),
31
31
  videoOptions_(videoOptions),
32
32
  outputFile_(outputFile),
33
- captureAudio_(captureAudio) {}
33
+ captureAudio_(captureAudio) {
34
+ // Forced on regardless of what the caller passed — see VideoEncoderOptions::fixedFrameSize.
35
+ videoOptions_.fixedFrameSize = true;
36
+ }
34
37
 
35
38
  ~Impl() { TearDownIfNeeded(); }
36
39
 
@@ -49,18 +52,31 @@ class AVRecordingSession::Impl {
49
52
  throw NSErrorException(writerError ?: MakeError(1, @"Failed to create an AVAssetWriter"));
50
53
  }
51
54
 
55
+ // A weak_ptr (not `this`, and not a strong shared_ptr) captured by every callback handed to a
56
+ // collaborator below: `this` would dangle if a callback fires mid-teardown (see Fail()'s own
57
+ // comment), but a strong self-capture stored in a member `this` itself owns (videoEncoder_,
58
+ // audioTap_) would leak instead, keeping this Impl alive forever via a reference cycle. Locking
59
+ // it at call time gets safety without either problem.
60
+ std::weak_ptr<Impl> weakSelf = weak_from_this();
52
61
  try {
53
62
  if (captureAudio_) {
54
63
  audioTap_ = std::make_unique<AudioTapSession>(
55
- udid_, [this](const AudioBufferList* data, const AudioTimeStamp* time) { HandleAudioPCM(data, time); },
56
- [this](NSError* error) {
64
+ udid_,
65
+ [weakSelf](const AudioBufferList* data, const AudioTimeStamp* time) {
66
+ if (auto self = weakSelf.lock()) {
67
+ self->HandleAudioPCM(data, time);
68
+ }
69
+ },
70
+ [weakSelf](NSError* error) {
57
71
  // A failed process-list refresh (kAudioTapNonFatalProcessListRefreshErrorCode) isn't
58
72
  // fatal — the tap keeps running. Everything else is.
59
73
  if ([error.domain isEqualToString:kAudioTapErrorDomain] &&
60
74
  error.code == kAudioTapNonFatalProcessListRefreshErrorCode) {
61
75
  return;
62
76
  }
63
- Fail(error, /*fromVideo=*/false);
77
+ if (auto self = weakSelf.lock()) {
78
+ self->Fail(error, /*fromVideo=*/false);
79
+ }
64
80
  },
65
81
  [] {});
66
82
  audioTap_->Start();
@@ -68,7 +84,12 @@ class AVRecordingSession::Impl {
68
84
  // Start() can invoke HandleAudioPCM before returning, so audioEncoder_ is written under
69
85
  // mutex_ (same lock HandleAudioPCM reads it under) to avoid a torn/racy pointer read.
70
86
  auto encoder = std::make_unique<AudioEncoder>(
71
- audioTap_->Format(), [this](CMSampleBufferRef sampleBuffer) { HandleAudioSample(sampleBuffer); },
87
+ audioTap_->Format(),
88
+ [weakSelf](CMSampleBufferRef sampleBuffer) {
89
+ if (auto self = weakSelf.lock()) {
90
+ self->HandleAudioSample(sampleBuffer);
91
+ }
92
+ },
72
93
  &clockOrigin_);
73
94
  {
74
95
  std::lock_guard<std::mutex> lock(mutex_);
@@ -83,9 +104,19 @@ class AVRecordingSession::Impl {
83
104
  [writer_ addInput:audioInput_];
84
105
  }
85
106
 
86
- videoEncoder_ = std::make_unique<VideoFrameEncoder>(
87
- device_, videoOptions_, [this](CMSampleBufferRef sampleBuffer) { HandleVideoSample(sampleBuffer); },
88
- [this](NSError* error) { Fail(error, /*fromVideo=*/true); }, [] {}, &clockOrigin_);
107
+ videoEncoder_ = std::make_shared<VideoFrameEncoder>(
108
+ device_, videoOptions_,
109
+ [weakSelf](CMSampleBufferRef sampleBuffer) {
110
+ if (auto self = weakSelf.lock()) {
111
+ self->HandleVideoSample(sampleBuffer);
112
+ }
113
+ },
114
+ [weakSelf](NSError* error) {
115
+ if (auto self = weakSelf.lock()) {
116
+ self->Fail(error, /*fromVideo=*/true);
117
+ }
118
+ },
119
+ [] {}, &clockOrigin_);
89
120
  videoEncoder_->Start();
90
121
  } catch (...) {
91
122
  TearDownIfNeeded();
@@ -185,7 +216,7 @@ class AVRecordingSession::Impl {
185
216
  [writer_ startSessionAtSourceTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
186
217
  writerStarted_ = true;
187
218
  justStarted = true;
188
- AppendVideoLocked(sampleBuffer);
219
+ writingError = AppendVideoLocked(sampleBuffer);
189
220
  for (CMSampleBufferRef pending : pendingAudio_) {
190
221
  AppendAudioLocked(pending);
191
222
  CFRelease(pending);
@@ -195,7 +226,7 @@ class AVRecordingSession::Impl {
195
226
  writingError = writer_.error ?: MakeError(3, @"AVAssetWriter startWriting failed");
196
227
  }
197
228
  } else {
198
- AppendVideoLocked(sampleBuffer);
229
+ writingError = AppendVideoLocked(sampleBuffer);
199
230
  }
200
231
  }
201
232
  if (writingError != nil) {
@@ -245,11 +276,16 @@ class AVRecordingSession::Impl {
245
276
  }
246
277
  }
247
278
 
248
- // Caller holds mutex_.
249
- void AppendVideoLocked(CMSampleBufferRef sampleBuffer) {
250
- if (videoInput_.isReadyForMoreMediaData) {
251
- [videoInput_ appendSampleBuffer:sampleBuffer];
279
+ // Caller holds mutex_. Returns a descriptive error if the append failed (e.g. a mid-recording
280
+ // rotation resizes the encoder but a track's dimensions are fixed for the writer's life), nil otherwise.
281
+ NSError* AppendVideoLocked(CMSampleBufferRef sampleBuffer) {
282
+ if (!videoInput_.isReadyForMoreMediaData) {
283
+ return nil; // transient backpressure, not a failure
284
+ }
285
+ if ([videoInput_ appendSampleBuffer:sampleBuffer]) {
286
+ return nil;
252
287
  }
288
+ return writer_.error ?: MakeError(4, @"Failed to append a video sample to the recording");
253
289
  }
254
290
 
255
291
  // Caller holds mutex_.
@@ -314,19 +350,23 @@ class AVRecordingSession::Impl {
314
350
  return pending;
315
351
  }
316
352
 
317
- // Called from either encoder's onError, or from HandleVideoSample on a writer-level failure —
318
- // not holding mutex_ (could deadlock against that same encoder's own queue). Stops only the
319
- // OTHER (still-running) encoder — the one whose callback we're inside is already tearing itself
320
- // down on its own error path, and calling its own blocking Stop() here would deadlock.
353
+ // Called from either encoder's onError, or from HandleVideoSample on a writer-level append
354
+ // failure — not holding mutex_ (could deadlock against that encoder's own queue).
355
+ //
356
+ // fromVideo=true also needs videoEncoder_ itself stopped: a writer append failure leaves
357
+ // VideoToolbox otherwise healthy and encoding forever. But videoEncoder_->Stop() dispatch_syncs
358
+ // onto its own queue, and HandleVideoSample (VideoToolbox's own output callback) isn't
359
+ // guaranteed to be off that queue — so it's dispatched async instead, via a shared_ptr `encoder`
360
+ // copy that can outlive this Impl. Stop() can still flush a pending frame into onSample_/onError_
361
+ // synchronously; those hold only a weak_ptr (see Start()), but `self` below keeps it resolvable
362
+ // for the duration of this block. onEnd_ waits until that's done, not fired eagerly here.
321
363
  void Fail(NSError* error, bool fromVideo) {
322
364
  if (fromVideo) {
323
365
  if (audioTap_) {
324
366
  audioTap_->Stop();
325
367
  }
326
- } else {
327
- if (videoEncoder_) {
328
- videoEncoder_->Stop();
329
- }
368
+ } else if (videoEncoder_) {
369
+ videoEncoder_->Stop();
330
370
  }
331
371
  std::vector<std::function<void(NSError*)>> pending;
332
372
  {
@@ -338,7 +378,16 @@ class AVRecordingSession::Impl {
338
378
  callback(error);
339
379
  }
340
380
  }
341
- FireEndOnce();
381
+ if (fromVideo && videoEncoder_) {
382
+ std::shared_ptr<VideoFrameEncoder> encoder = videoEncoder_;
383
+ std::shared_ptr<Impl> self = shared_from_this();
384
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
385
+ encoder->Stop();
386
+ self->FireEndOnce();
387
+ });
388
+ } else {
389
+ FireEndOnce();
390
+ }
342
391
  }
343
392
 
344
393
  // The single point where a real (first-ever) Stop() attempt's outcome becomes known — records
@@ -389,7 +438,7 @@ class AVRecordingSession::Impl {
389
438
  std::function<void()> onEnd_;
390
439
 
391
440
  double clockOrigin_ = 0;
392
- std::unique_ptr<VideoFrameEncoder> videoEncoder_;
441
+ std::shared_ptr<VideoFrameEncoder> videoEncoder_;
393
442
  std::unique_ptr<AudioTapSession> audioTap_;
394
443
  std::unique_ptr<AudioEncoder> audioEncoder_;
395
444
 
@@ -410,7 +459,7 @@ class AVRecordingSession::Impl {
410
459
 
411
460
  AVRecordingSession::AVRecordingSession(id device, NSString* udid, VideoEncoderOptions videoOptions,
412
461
  NSString* outputFile, bool captureAudio)
413
- : impl_(std::make_unique<Impl>(device, udid, videoOptions, outputFile, captureAudio)) {}
462
+ : impl_(std::make_shared<Impl>(device, udid, videoOptions, outputFile, captureAudio)) {}
414
463
 
415
464
  AVRecordingSession::~AVRecordingSession() = default;
416
465
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  #import <Foundation/Foundation.h>
4
4
 
5
+ #include <cstdint>
6
+
5
7
  namespace coresim {
6
8
 
7
9
  // -[SimDevice UDID] / -[SimDevice name] / -[SimDevice state] / -[SimDevice deviceType] /
@@ -19,6 +21,11 @@ id DeviceRuntime(id device);
19
21
  // privacy-permission access (see tcc_privacy.h).
20
22
  NSString* DeviceDataPath(id device);
21
23
 
24
+ // -[SimDevice lastBootedAt] -> the current boot session's start time (nil if never booted). Used to
25
+ // tell a guest preference file that predates this boot from one already rewritten this boot (see
26
+ // sim_orientation.mm).
27
+ NSDate* DeviceLastBootedAt(id device);
28
+
22
29
  // -[SimDevice bootWithOptions:error:]
23
30
  BOOL Boot(id device, NSDictionary* options, NSError** error);
24
31
 
@@ -64,6 +71,10 @@ NSString* Getenv(id device, NSString* name, NSError** error);
64
71
  // the same way Apple's own Simulator.app does.
65
72
  unsigned int LookupMachPort(id device, NSString* serviceName, NSError** error);
66
73
 
74
+ // Rotates the device via a raw GSEvent mach message to SpringBoard's "PurpleWorkspacePort" (see
75
+ // CLAUDE.md). `orientation`: 1=portrait, 2=portraitUpsideDown, 3=landscapeRight, 4=landscapeLeft.
76
+ BOOL SetDeviceOrientation(id device, int32_t orientation, NSError** error);
77
+
67
78
  // -[SimDevice installApplication:withOptions:error:]
68
79
  BOOL InstallApp(id device, NSURL* appURL, NSDictionary* options, NSError** error);
69
80
 
@@ -1,7 +1,10 @@
1
1
  #include "sim_device.h"
2
2
 
3
+ #import <mach/mach.h>
3
4
  #import <objc/message.h>
4
5
 
6
+ #include <cstring>
7
+
5
8
  #include "objc_runtime.h"
6
9
  #include "safe_dispatch.h"
7
10
 
@@ -9,6 +12,12 @@ namespace coresim {
9
12
 
10
13
  namespace {
11
14
 
15
+ NSString* const kOrientationErrorDomain = @"io.appium.coresim.Orientation";
16
+
17
+ NSError* MakeOrientationError(NSInteger code, NSString* message) {
18
+ return [NSError errorWithDomain:kOrientationErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
19
+ }
20
+
12
21
  // Shared shapes reused across the many identical-signature getters/setters below.
13
22
  id IdGetter(id target, const std::string& selectorName) {
14
23
  RequireSelector(target, selectorName);
@@ -53,6 +62,7 @@ NSString* DeviceName(id device) { return IdGetter(device, "name"); }
53
62
  id DeviceDeviceType(id device) { return IdGetter(device, "deviceType"); }
54
63
  id DeviceRuntime(id device) { return IdGetter(device, "runtime"); }
55
64
  NSString* DeviceDataPath(id device) { return IdGetter(device, "dataPath"); }
65
+ NSDate* DeviceLastBootedAt(id device) { return IdGetter(device, "lastBootedAt"); }
56
66
 
57
67
  unsigned long long DeviceState(id device) {
58
68
  static const std::string kSelectorName = "state";
@@ -115,6 +125,49 @@ unsigned int LookupMachPort(id device, NSString* serviceName, NSError** error) {
115
125
  });
116
126
  }
117
127
 
128
+ BOOL SetDeviceOrientation(id device, int32_t orientation, NSError** error) {
129
+ NSError* lookupError = nil;
130
+ unsigned int purplePort = LookupMachPort(device, @"PurpleWorkspacePort", &lookupError);
131
+ // The port is the authoritative success signal, not the error (see LookupMachPort's own callers,
132
+ // e.g. sim_pasteboard.mm).
133
+ if (purplePort == 0) {
134
+ *error = lookupError ?: MakeOrientationError(1, @"PurpleWorkspacePort is not available — is the device booted?");
135
+ return NO;
136
+ }
137
+
138
+ // GSEvent wire format, undocumented (see CLAUDE.md): mach_msg_header_t + a fixed-layout event
139
+ // record, in a 112-byte buffer (8-byte aligned, >= the 108-byte message mach_msg sends).
140
+ constexpr uint32_t kGSEventTypeDeviceOrientationChanged = 50;
141
+ constexpr uint32_t kGSEventHostFlag = 0x20000;
142
+ constexpr mach_msg_id_t kGSEventMachMessageID = 0x7B;
143
+ constexpr mach_msg_timeout_t kSendTimeoutMs = 2000;
144
+
145
+ uint8_t buffer[112] = {0};
146
+ auto* header = reinterpret_cast<mach_msg_header_t*>(buffer);
147
+ header->msgh_bits = 0x13; // MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0)
148
+ header->msgh_size = 108; // align4(4 + 0x6B) — matches Simulator.app's own sendPurpleEvent:
149
+ header->msgh_remote_port = purplePort;
150
+ header->msgh_local_port = MACH_PORT_NULL;
151
+ header->msgh_id = kGSEventMachMessageID;
152
+
153
+ uint32_t type = kGSEventTypeDeviceOrientationChanged | kGSEventHostFlag;
154
+ std::memcpy(buffer + 0x18, &type, sizeof(type));
155
+ uint32_t recordInfoSize = 4;
156
+ std::memcpy(buffer + 0x48, &recordInfoSize, sizeof(recordInfoSize));
157
+ uint32_t orientationValue = static_cast<uint32_t>(orientation);
158
+ std::memcpy(buffer + 0x4C, &orientationValue, sizeof(orientationValue));
159
+
160
+ kern_return_t kr = mach_msg(header, MACH_SEND_MSG | MACH_SEND_TIMEOUT, header->msgh_size, 0, MACH_PORT_NULL,
161
+ kSendTimeoutMs, MACH_PORT_NULL);
162
+ if (kr != KERN_SUCCESS) {
163
+ *error = MakeOrientationError(
164
+ 2, [NSString stringWithFormat:@"Failed to send the orientation change (kern_return_t %d): %s", kr,
165
+ mach_error_string(kr)]);
166
+ return NO;
167
+ }
168
+ return YES;
169
+ }
170
+
118
171
  BOOL InstallApp(id device, NSURL* appURL, NSDictionary* options, NSError** error) {
119
172
  static const std::string kSelectorName = "installApplication:withOptions:error:";
120
173
  RequireSelector(device, kSelectorName);
@@ -0,0 +1,16 @@
1
+ #pragma once
2
+
3
+ #import <Foundation/Foundation.h>
4
+
5
+ #include <cstdint>
6
+
7
+ namespace coresim {
8
+
9
+ // A live orientation read straight off backboardd's own persisted preference file on the host side
10
+ // (see CLAUDE.md) — reflects a rotation from any source, not just this process's own
11
+ // SetDeviceOrientation calls. Calls `handler` once, async, with a DeviceOrientation wire value
12
+ // (types.ts) — always 1 (portrait) if the device hasn't rotated yet this boot, or on any read
13
+ // failure.
14
+ void ReadGuestOrientation(id device, void (^handler)(int32_t orientation));
15
+
16
+ } // namespace coresim
@@ -0,0 +1,58 @@
1
+ #include "sim_orientation.h"
2
+
3
+ #include "sim_device.h"
4
+
5
+ namespace coresim {
6
+
7
+ namespace {
8
+
9
+ // backboardd's persisted GraphicsOrientation swaps landscape left/right vs. our own
10
+ // DeviceOrientation wire values — confirmed empirically (see CLAUDE.md).
11
+ int32_t TranslateGraphicsOrientation(NSInteger graphicsOrientation) {
12
+ switch (graphicsOrientation) {
13
+ case 3:
14
+ return 4;
15
+ case 4:
16
+ return 3;
17
+ default:
18
+ return static_cast<int32_t>(graphicsOrientation);
19
+ }
20
+ }
21
+
22
+ // The live digitizer entry for the current boot, or nil if it hasn't rotated yet this boot (or
23
+ // `plist` is nil/malformed). Reading straight from the file (a real binary plist) gives properly
24
+ // typed values — no ASCII-vs-binary parsing ambiguity to handle.
25
+ NSNumber* CurrentGraphicsOrientation(NSDictionary* plist) {
26
+ NSArray* entries = [plist[@"BKDigitizerPersistentServiceProperties"] isKindOfClass:[NSArray class]]
27
+ ? plist[@"BKDigitizerPersistentServiceProperties"]
28
+ : nil;
29
+ NSDictionary* last = [entries.lastObject isKindOfClass:[NSDictionary class]] ? entries.lastObject : nil;
30
+ NSDictionary* props = [last[@"props"] isKindOfClass:[NSDictionary class]] ? last[@"props"] : nil;
31
+ return [props[@"GraphicsOrientation"] isKindOfClass:[NSNumber class]] ? props[@"GraphicsOrientation"] : nil;
32
+ }
33
+
34
+ } // namespace
35
+
36
+ void ReadGuestOrientation(id device, void (^handler)(int32_t orientation)) {
37
+ NSString* plistPath =
38
+ [DeviceDataPath(device) stringByAppendingPathComponent:@"Library/Preferences/com.apple.backboardd.plist"];
39
+ NSDate* lastBootedAt = DeviceLastBootedAt(device);
40
+
41
+ dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
42
+ NSDate* modified = [[NSFileManager defaultManager] attributesOfItemAtPath:plistPath
43
+ error:nil][NSFileModificationDate];
44
+ // The whole file predates this boot, so none of its entries — including the last one — can
45
+ // belong to it (confirmed empirically: a reboot leaves the previous boot's entry in place until
46
+ // backboardd rewrites the file, sometimes several seconds after boot completes — see CLAUDE.md).
47
+ // Treat exactly like "never rotated this boot" rather than trusting stale content.
48
+ if (lastBootedAt != nil && modified != nil && [modified compare:lastBootedAt] == NSOrderedAscending) {
49
+ handler(1);
50
+ return;
51
+ }
52
+ NSDictionary* plist = [NSDictionary dictionaryWithContentsOfFile:plistPath];
53
+ NSNumber* graphicsOrientation = CurrentGraphicsOrientation(plist);
54
+ handler(graphicsOrientation != nil ? TranslateGraphicsOrientation(graphicsOrientation.integerValue) : 1);
55
+ });
56
+ }
57
+
58
+ } // namespace coresim
@@ -18,6 +18,11 @@ NSArray<NSDictionary*>* ListDisplays(id device, NSError** error);
18
18
  // object itself (e.g. StartVideoRecording's `screen` argument).
19
19
  id ResolveCaptureDisplay(id device, NSString* displayId, NSError** error);
20
20
 
21
+ // The device-wide "capture service" port (real protocol: SimScreenCaptureService — see CLAUDE.md),
22
+ // distinct from a display descriptor (see ResolveCaptureDisplay above). Found by scanning ioPorts
23
+ // since no header exists. Throws NativeSimUnavailableError (not NSError**) if absent.
24
+ id ResolveScreenCaptureService(id device, NSError** error);
25
+
21
26
  // The descriptor's current framebuffer as an `IOSurfaceRef` (bridge-cast the returned `id`).
22
27
  // Returns nil if not available yet (e.g. connection just dropped) — not an error, since that can
23
28
  // legitimately happen from one call to the next on a live device.
@@ -152,6 +152,27 @@ id ResolveCaptureDisplay(id device, NSString* displayId, NSError** error) {
152
152
  return ResolveDisplayDescriptor(candidates, displayId, error);
153
153
  }
154
154
 
155
+ // The real protocol is SimScreenCaptureService (see CLAUDE.md) — found by scanning ioPorts for
156
+ // whichever descriptor responds to startRecordingFromScreen:..., since no header exists for it.
157
+ id ResolveScreenCaptureService(id device, NSError** error) {
158
+ static const std::string kStartRecordingSelector =
159
+ "startRecordingFromScreen:maskPolicy:assetWriterOutputSettings:outputFile:completionQueue:completionHandler:";
160
+ id ioClient = IdGetter(device, "io");
161
+ if (ioClient == nil) {
162
+ *error = MakeError(11, @"Device has no IO client available — is it booted?");
163
+ return nil;
164
+ }
165
+ NSArray* ports = IdGetter(ioClient, "ioPorts");
166
+ SEL selector = NSSelectorFromString(@(kStartRecordingSelector.c_str()));
167
+ for (id port in ports) {
168
+ id descriptor = IdGetter(port, "descriptor");
169
+ if (descriptor != nil && [descriptor respondsToSelector:selector]) {
170
+ return descriptor;
171
+ }
172
+ }
173
+ throw NativeSimUnavailableError("selector", kStartRecordingSelector, CoreSimulatorFrameworkVersion());
174
+ }
175
+
155
176
  id CurrentDisplaySurface(id descriptor) { return RenderableSurface(descriptor); }
156
177
 
157
178
  NSArray<NSDictionary*>* ListDisplays(id device, NSError** error) {
@@ -8,50 +8,9 @@
8
8
 
9
9
  namespace coresim {
10
10
 
11
- namespace {
12
-
13
- NSString* const kVideoRecordingErrorDomain = @"io.appium.coresim.VideoRecording";
14
-
15
- NSError* MakeError(NSInteger code, NSString* message) {
16
- return [NSError errorWithDomain:kVideoRecordingErrorDomain code:code userInfo:@{NSLocalizedDescriptionKey : message}];
17
- }
18
-
19
- id IdGetter(id target, const std::string& selectorName) {
20
- RequireSelector(target, selectorName);
21
- SEL selector = SelectorNamed(selectorName);
22
- return SafeInvoke([&] {
23
- using Fn = id (*)(id, SEL);
24
- return ((Fn)objc_msgSend)(target, selector);
25
- });
26
- }
27
-
28
- // The device-wide "capture service" port (real protocol: SimScreenCaptureService — see CLAUDE.md),
29
- // distinct from the display descriptor passed as `screen` below. Found by scanning ioPorts since
30
- // no header exists. Throws NativeSimUnavailableError (not NSError**) if absent.
31
- id ResolveVideoCaptureService(id device, NSError** error) {
32
- static const std::string kStartRecordingSelector =
33
- "startRecordingFromScreen:maskPolicy:assetWriterOutputSettings:outputFile:completionQueue:completionHandler:";
34
- id ioClient = IdGetter(device, "io");
35
- if (ioClient == nil) {
36
- *error = MakeError(1, @"Device has no IO client available — is it booted?");
37
- return nil;
38
- }
39
- NSArray* ports = IdGetter(ioClient, "ioPorts");
40
- SEL selector = NSSelectorFromString(@(kStartRecordingSelector.c_str()));
41
- for (id port in ports) {
42
- id descriptor = IdGetter(port, "descriptor");
43
- if (descriptor != nil && [descriptor respondsToSelector:selector]) {
44
- return descriptor;
45
- }
46
- }
47
- throw NativeSimUnavailableError("selector", kStartRecordingSelector, CoreSimulatorFrameworkVersion());
48
- }
49
-
50
- } // namespace
51
-
52
11
  BOOL StartVideoRecording(id device, NSString* displayId, VideoMaskPolicy mask, NSDictionary* assetWriterOutputSettings,
53
12
  NSString* outputFile, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error) {
54
- id captureService = ResolveVideoCaptureService(device, error);
13
+ id captureService = ResolveScreenCaptureService(device, error);
55
14
  if (captureService == nil) {
56
15
  return NO;
57
16
  }
@@ -71,7 +30,7 @@ BOOL StartVideoRecording(id device, NSString* displayId, VideoMaskPolicy mask, N
71
30
  }
72
31
 
73
32
  BOOL StopVideoRecording(id device, dispatch_queue_t queue, void (^handler)(NSError*), NSError** error) {
74
- id captureService = ResolveVideoCaptureService(device, error);
33
+ id captureService = ResolveScreenCaptureService(device, error);
75
34
  if (captureService == nil) {
76
35
  return NO;
77
36
  }
@@ -17,6 +17,13 @@ struct VideoEncoderOptions {
17
17
  NSString* displayId = nil;
18
18
  double fps = 60.0;
19
19
  int bitrate = 4000000;
20
+
21
+ // Freezes the encoded frame size at whatever it is on the first frame, forever — a rotation
22
+ // afterward letterboxes into that size instead of resizing the session to match. Internal-only
23
+ // (not JS-configurable): AVRecordingSession forces this on, since its AVAssetWriter track's
24
+ // dimensions are fixed for the file's life (see CLAUDE.md); a live stream has no such constraint
25
+ // and keeps the default (false), matching its own decoded dimensions to the actual rotation.
26
+ bool fixedFrameSize = false;
20
27
  };
21
28
 
22
29
  // Polls the live display IOSurface (sim_screenshot.h) on a serial queue and encodes changed