Skip to content

Commit eda6398

Browse files
authored
Add bezel-aware screenshots and screen recording support (#46)
* Add bezeled screenshots and screen recording * Stabilize simulator screen recording * Avoid blocking during recorder cleanup
1 parent 0cef9be commit eda6398

33 files changed

Lines changed: 945 additions & 28 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ Useful direct commands:
137137
./build/simdeck pasteboard set <udid> "hello"
138138
./build/simdeck pasteboard get <udid>
139139
./build/simdeck screenshot <udid> --output screen.png
140+
./build/simdeck screenshot <udid> --with-bezel --output screen-bezel.png
141+
./build/simdeck record <udid> --seconds 5 --output screen-recording.mp4
140142
./build/simdeck describe <udid>
141143
./build/simdeck tap <udid> 120 240
142144
./build/simdeck tap <udid> --label "Continue" --wait-timeout-ms 5000

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ simdeck toggle-appearance <udid>
130130
simdeck pasteboard set <udid> "hello"
131131
simdeck pasteboard get <udid>
132132
simdeck screenshot <udid> --output screen.png
133+
simdeck screenshot <udid> --with-bezel --output screen-bezel.png
134+
simdeck record <udid> --seconds 5 --output screen-recording.mp4
133135
simdeck stream <udid> --frames 120 > stream.h264
134136
simdeck describe <udid>
135137
simdeck describe <udid> --format agent --max-depth 4
@@ -206,6 +208,8 @@ try {
206208
await sim.tap(0.5, 0.5);
207209
await sim.waitFor({ label: "Continue" });
208210
await sim.screenshot();
211+
await sim.screenshot({ withBezel: true });
212+
await sim.record({ seconds: 5 });
209213
} finally {
210214
sim.close();
211215
}

cli/XCWChromeRenderer.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ NS_ASSUME_NONNULL_BEGIN
1515
error:(NSError * _Nullable * _Nullable)error;
1616
+ (nullable NSData *)screenMaskPNGDataForDeviceName:(NSString *)deviceName
1717
error:(NSError * _Nullable * _Nullable)error;
18+
+ (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName
19+
screenPNGData:(NSData *)screenPNGData
20+
error:(NSError * _Nullable * _Nullable)error;
1821
+ (nullable NSDictionary<NSString *, id> *)profileForDeviceName:(NSString *)deviceName
1922
error:(NSError * _Nullable * _Nullable)error;
2023

cli/XCWChromeRenderer.m

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,111 @@ + (nullable NSData *)screenMaskPNGDataForDeviceName:(NSString *)deviceName
249249
return [self PNGDataForPDFAtPath:maskPath scale:1.0 error:error];
250250
}
251251

252+
+ (nullable NSData *)screenshotPNGDataForDeviceName:(NSString *)deviceName
253+
screenPNGData:(NSData *)screenPNGData
254+
error:(NSError * _Nullable __autoreleasing *)error {
255+
NSDictionary *profile = [self profileForDeviceName:deviceName error:error];
256+
if (profile == nil) {
257+
return nil;
258+
}
259+
260+
NSData *chromePNGData = [self PNGDataForDeviceName:deviceName includeButtons:YES error:error];
261+
if (chromePNGData == nil) {
262+
return nil;
263+
}
264+
265+
NSImage *screenImage = [[NSImage alloc] initWithData:screenPNGData];
266+
NSImage *chromeImage = [[NSImage alloc] initWithData:chromePNGData];
267+
if (screenImage == nil || chromeImage == nil) {
268+
if (error != NULL) {
269+
*error = [NSError errorWithDomain:XCWChromeRendererErrorDomain
270+
code:15
271+
userInfo:@{
272+
NSLocalizedDescriptionKey: @"Unable to decode simulator screenshot or chrome PNG data.",
273+
}];
274+
}
275+
return nil;
276+
}
277+
278+
CGFloat scale = 3.0;
279+
CGFloat totalWidth = [self numberValue:profile[@"totalWidth"]];
280+
CGFloat totalHeight = [self numberValue:profile[@"totalHeight"]];
281+
CGFloat screenX = [self numberValue:profile[@"screenX"]];
282+
CGFloat screenY = [self numberValue:profile[@"screenY"]];
283+
CGFloat screenWidth = [self numberValue:profile[@"screenWidth"]];
284+
CGFloat screenHeight = [self numberValue:profile[@"screenHeight"]];
285+
NSInteger pixelWidth = MAX((NSInteger)ceil(totalWidth * scale), 1);
286+
NSInteger pixelHeight = MAX((NSInteger)ceil(totalHeight * scale), 1);
287+
if (totalWidth <= 0.0 || totalHeight <= 0.0 || screenWidth <= 0.0 || screenHeight <= 0.0) {
288+
if (error != NULL) {
289+
*error = [NSError errorWithDomain:XCWChromeRendererErrorDomain
290+
code:16
291+
userInfo:@{
292+
NSLocalizedDescriptionKey: @"Device chrome profile did not include usable screenshot geometry.",
293+
}];
294+
}
295+
return nil;
296+
}
297+
298+
NSBitmapImageRep *bitmap = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL
299+
pixelsWide:pixelWidth
300+
pixelsHigh:pixelHeight
301+
bitsPerSample:8
302+
samplesPerPixel:4
303+
hasAlpha:YES
304+
isPlanar:NO
305+
colorSpaceName:NSDeviceRGBColorSpace
306+
bytesPerRow:0
307+
bitsPerPixel:32];
308+
if (bitmap == nil) {
309+
if (error != NULL) {
310+
*error = [NSError errorWithDomain:XCWChromeRendererErrorDomain
311+
code:17
312+
userInfo:@{
313+
NSLocalizedDescriptionKey: @"Unable to create a bitmap for bezeled screenshot rendering.",
314+
}];
315+
}
316+
return nil;
317+
}
318+
319+
NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmap];
320+
[NSGraphicsContext saveGraphicsState];
321+
[NSGraphicsContext setCurrentContext:graphicsContext];
322+
graphicsContext.imageInterpolation = NSImageInterpolationHigh;
323+
NSRect outputRect = NSMakeRect(0.0, 0.0, pixelWidth, pixelHeight);
324+
[[NSColor clearColor] setFill];
325+
NSRectFillUsingOperation(outputRect, NSCompositingOperationClear);
326+
327+
NSRect screenRect = NSMakeRect(screenX * scale,
328+
pixelHeight - ((screenY + screenHeight) * scale),
329+
screenWidth * scale,
330+
screenHeight * scale);
331+
NSDictionary *hints = @{ NSImageHintInterpolation: @(NSImageInterpolationHigh) };
332+
[screenImage drawInRect:screenRect
333+
fromRect:NSZeroRect
334+
operation:NSCompositingOperationSourceOver
335+
fraction:1.0
336+
respectFlipped:NO
337+
hints:hints];
338+
[chromeImage drawInRect:outputRect
339+
fromRect:NSZeroRect
340+
operation:NSCompositingOperationSourceOver
341+
fraction:1.0
342+
respectFlipped:NO
343+
hints:hints];
344+
[NSGraphicsContext restoreGraphicsState];
345+
346+
NSData *pngData = [bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
347+
if (pngData.length == 0 && error != NULL) {
348+
*error = [NSError errorWithDomain:XCWChromeRendererErrorDomain
349+
code:18
350+
userInfo:@{
351+
NSLocalizedDescriptionKey: @"Unable to encode bezeled simulator screenshot PNG.",
352+
}];
353+
}
354+
return pngData.length > 0 ? pngData : nil;
355+
}
356+
252357
+ (nullable NSDictionary<NSString *, id> *)profileForChromeInfo:(NSDictionary *)chromeInfo
253358
error:(NSError * _Nullable __autoreleasing *)error {
254359
NSDictionary *plist = chromeInfo[@"plist"];

cli/XCWProcessRunner.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ NS_ASSUME_NONNULL_BEGIN
3131
timeoutSec:(NSTimeInterval)timeoutSec
3232
error:(NSError * _Nullable * _Nullable)error;
3333

34+
+ (XCWProcessResult *)runLaunchPath:(NSString *)launchPath
35+
arguments:(NSArray<NSString *> *)arguments
36+
inputData:(nullable NSData *)inputData
37+
timeoutSec:(NSTimeInterval)timeoutSec
38+
timeoutSignal:(int)timeoutSignal
39+
error:(NSError * _Nullable * _Nullable)error;
40+
3441
@end
3542

3643
NS_ASSUME_NONNULL_END

cli/XCWProcessRunner.m

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,20 @@ + (XCWProcessResult *)runLaunchPath:(NSString *)launchPath
119119
inputData:(NSData *)inputData
120120
timeoutSec:(NSTimeInterval)timeoutSec
121121
error:(NSError * _Nullable __autoreleasing *)error {
122+
return [self runLaunchPath:launchPath
123+
arguments:arguments
124+
inputData:inputData
125+
timeoutSec:timeoutSec
126+
timeoutSignal:SIGTERM
127+
error:error];
128+
}
129+
130+
+ (XCWProcessResult *)runLaunchPath:(NSString *)launchPath
131+
arguments:(NSArray<NSString *> *)arguments
132+
inputData:(NSData *)inputData
133+
timeoutSec:(NSTimeInterval)timeoutSec
134+
timeoutSignal:(int)timeoutSignal
135+
error:(NSError * _Nullable __autoreleasing *)error {
122136
int stdoutFD = -1;
123137
int stderrFD = -1;
124138
int stdinPipe[2] = { -1, -1 };
@@ -244,8 +258,10 @@ + (XCWProcessResult *)runLaunchPath:(NSString *)launchPath
244258
}
245259
if (hasTimeout && [deadline timeIntervalSinceNow] <= 0) {
246260
timedOut = YES;
247-
kill(pid, SIGTERM);
248-
NSDate *killDeadline = [NSDate dateWithTimeIntervalSinceNow:2.0];
261+
int signalToSend = timeoutSignal > 0 ? timeoutSignal : SIGTERM;
262+
kill(pid, signalToSend);
263+
NSTimeInterval graceSeconds = signalToSend == SIGINT ? 10.0 : 2.0;
264+
NSDate *killDeadline = [NSDate dateWithTimeIntervalSinceNow:graceSeconds];
249265
do {
250266
waitResult = waitpid(pid, &waitStatus, WNOHANG);
251267
if (waitResult == pid || (waitResult < 0 && errno != EINTR)) {

cli/XCWSimctl.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ NS_ASSUME_NONNULL_BEGIN
1919
- (BOOL)openURL:(NSString *)urlString simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
2020
- (BOOL)launchBundleID:(NSString *)bundleID simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
2121
- (nullable NSData *)screenshotPNGForSimulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
22+
- (nullable NSData *)screenshotPNGForSimulatorUDID:(NSString *)udid
23+
includeBezel:(BOOL)includeBezel
24+
error:(NSError * _Nullable * _Nullable)error;
25+
- (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
26+
durationSeconds:(NSTimeInterval)durationSeconds
27+
error:(NSError * _Nullable * _Nullable)error;
2228
- (BOOL)eraseSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
2329
- (BOOL)installAppAtPath:(NSString *)appPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
2430
- (BOOL)uninstallBundleID:(NSString *)bundleID simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;

0 commit comments

Comments
 (0)