browsertime 26.3.1 → 27.0.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 +71 -2
- package/README.md +1 -1
- package/browserscripts/timings/softNavigations.js +94 -0
- package/lib/chrome/har.js +36 -5
- package/lib/chrome/settings/chromeAndroidOptions.js +2 -1
- package/lib/chrome/webdriver/chromium.js +39 -0
- package/lib/chrome/webdriver/setupChromiumOptions.js +86 -31
- package/lib/core/engine/collector.js +340 -317
- package/lib/core/engine/command/addText.js +93 -12
- package/lib/core/engine/command/click.js +239 -139
- package/lib/core/engine/command/commandHelper.js +45 -0
- package/lib/core/engine/command/cookie.js +85 -0
- package/lib/core/engine/command/element.js +37 -2
- package/lib/core/engine/command/javaScript.js +14 -24
- package/lib/core/engine/command/measure/screenshots.js +114 -0
- package/lib/core/engine/command/measure/video.js +85 -0
- package/lib/core/engine/command/measure.js +104 -180
- package/lib/core/engine/command/mouse/clickAndHold.js +6 -3
- package/lib/core/engine/command/mouse/contextClick.js +56 -4
- package/lib/core/engine/command/mouse/doubleClick.js +49 -1
- package/lib/core/engine/command/mouse/mouseMove.js +50 -1
- package/lib/core/engine/command/mouse/singleClick.js +165 -129
- package/lib/core/engine/command/navigation.js +6 -6
- package/lib/core/engine/command/select.js +95 -3
- package/lib/core/engine/command/selectorParser.js +58 -0
- package/lib/core/engine/command/set.js +55 -1
- package/lib/core/engine/command/wait.js +41 -2
- package/lib/core/engine/commands.js +550 -42
- package/lib/core/engine/index.js +206 -150
- package/lib/core/engine/iteration.js +269 -137
- package/lib/firefox/networkManager.js +22 -30
- package/lib/firefox/webdriver/builder.js +14 -0
- package/lib/firefox/webdriver/firefox.js +1 -1
- package/lib/safari/har.js +131 -0
- package/lib/safari/native/ios-capture +0 -0
- package/lib/safari/native/ios-capture.m +340 -0
- package/lib/safari/safariInspectorClient.js +322 -0
- package/lib/safari/webdriver/safari.js +303 -13
- package/lib/safari/webkitToCdpAdapter.js +241 -0
- package/lib/support/cli.js +13 -0
- package/lib/support/engineUtils.js +16 -5
- package/lib/support/getViewPort.js +5 -0
- package/lib/support/har/index.js +41 -3
- package/lib/support/usbPower.js +2 -3
- package/lib/video/postprocessing/finetune/addTextToVideo.js +4 -0
- package/lib/video/postprocessing/finetune/getFont.js +3 -3
- package/lib/video/postprocessing/finetune/getTimingMetrics.js +7 -0
- package/lib/video/postprocessing/finetune/index.js +26 -9
- package/lib/video/screenRecording/ios/iosDeviceRecorder.js +109 -0
- package/lib/video/screenRecording/recorder.js +2 -2
- package/lib/video/screenRecording/setOrangeBackground.js +3 -0
- package/package.json +12 -12
- package/types/core/engine/command/addText.d.ts +35 -6
- package/types/core/engine/command/addText.d.ts.map +1 -1
- package/types/core/engine/command/click.d.ts +87 -16
- package/types/core/engine/command/click.d.ts.map +1 -1
- package/types/core/engine/command/commandHelper.d.ts +16 -0
- package/types/core/engine/command/commandHelper.d.ts.map +1 -0
- package/types/core/engine/command/cookie.d.ts +65 -0
- package/types/core/engine/command/cookie.d.ts.map +1 -0
- package/types/core/engine/command/element.d.ts +19 -1
- package/types/core/engine/command/element.d.ts.map +1 -1
- package/types/core/engine/command/javaScript.d.ts +4 -0
- package/types/core/engine/command/javaScript.d.ts.map +1 -1
- package/types/core/engine/command/measure/screenshots.d.ts +15 -0
- package/types/core/engine/command/measure/screenshots.d.ts.map +1 -0
- package/types/core/engine/command/measure/video.d.ts +26 -0
- package/types/core/engine/command/measure/video.d.ts.map +1 -0
- package/types/core/engine/command/measure.d.ts +59 -33
- package/types/core/engine/command/measure.d.ts.map +1 -1
- package/types/core/engine/command/mouse/clickAndHold.d.ts.map +1 -1
- package/types/core/engine/command/mouse/contextClick.d.ts +28 -4
- package/types/core/engine/command/mouse/contextClick.d.ts.map +1 -1
- package/types/core/engine/command/mouse/doubleClick.d.ts +24 -4
- package/types/core/engine/command/mouse/doubleClick.d.ts.map +1 -1
- package/types/core/engine/command/mouse/mouseMove.d.ts +30 -5
- package/types/core/engine/command/mouse/mouseMove.d.ts.map +1 -1
- package/types/core/engine/command/mouse/singleClick.d.ts +50 -13
- package/types/core/engine/command/mouse/singleClick.d.ts.map +1 -1
- package/types/core/engine/command/navigation.d.ts +3 -3
- package/types/core/engine/command/select.d.ts +45 -8
- package/types/core/engine/command/select.d.ts.map +1 -1
- package/types/core/engine/command/selectorParser.d.ts +20 -0
- package/types/core/engine/command/selectorParser.d.ts.map +1 -0
- package/types/core/engine/command/set.d.ts +34 -7
- package/types/core/engine/command/set.d.ts.map +1 -1
- package/types/core/engine/command/wait.d.ts +29 -6
- package/types/core/engine/command/wait.d.ts.map +1 -1
- package/types/core/engine/commands.d.ts +296 -28
- package/types/core/engine/commands.d.ts.map +1 -1
- package/types/support/engineUtils.d.ts.map +1 -1
- package/types/support/getViewPort.d.ts.map +1 -1
- package/types/support/usbPower.d.ts.map +1 -1
- package/types/video/postprocessing/finetune/addTextToVideo.d.ts.map +1 -1
- package/types/video/postprocessing/finetune/getTimingMetrics.d.ts.map +1 -1
- package/types/video/postprocessing/finetune/index.d.ts.map +1 -1
- package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts +28 -0
- package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts.map +1 -0
- package/types/video/screenRecording/recorder.d.ts +2 -2
- package/types/video/screenRecording/recorder.d.ts.map +1 -1
- package/types/video/screenRecording/setOrangeBackground.d.ts.map +1 -1
- package/types/video/video.d.ts +1 -1
- package/types/video/video.d.ts.map +1 -1
- package/visualmetrics/visualmetrics-portable.py +136 -136
- package/lib/video/screenRecording/ios/convertToMp4.js +0 -22
- package/lib/video/screenRecording/ios/iosRecorder.js +0 -68
|
Binary file
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* iOS screen capture server for Browsertime.
|
|
3
|
+
*
|
|
4
|
+
* Enables CoreMediaIO screen capture devices, finds the connected iOS device,
|
|
5
|
+
* and accepts commands via stdin to start/stop recording.
|
|
6
|
+
*
|
|
7
|
+
* Commands (via stdin):
|
|
8
|
+
* START <filepath> - Start recording to the given file path
|
|
9
|
+
* STOP - Stop recording
|
|
10
|
+
* QUIT - Stop recording and exit
|
|
11
|
+
*
|
|
12
|
+
* Responses (via stdout):
|
|
13
|
+
* READY - Device found, ready to accept commands
|
|
14
|
+
* RECORDING - Recording started
|
|
15
|
+
* STOPPED - Recording stopped, file written
|
|
16
|
+
* ERROR <message> - An error occurred
|
|
17
|
+
*
|
|
18
|
+
* Compile:
|
|
19
|
+
* clang -framework CoreMediaIO -framework AVFoundation -framework CoreVideo \
|
|
20
|
+
* -framework CoreMedia -framework Foundation ios-capture.m -o ios-capture
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
#import <Foundation/Foundation.h>
|
|
24
|
+
#import <AVFoundation/AVFoundation.h>
|
|
25
|
+
#import <CoreMediaIO/CMIOHardwareSystem.h>
|
|
26
|
+
#import <CoreMediaIO/CMIOHardwareDevice.h>
|
|
27
|
+
#import <CoreVideo/CoreVideo.h>
|
|
28
|
+
#import <CoreMedia/CoreMedia.h>
|
|
29
|
+
#import <signal.h>
|
|
30
|
+
#import <unistd.h>
|
|
31
|
+
|
|
32
|
+
static volatile sig_atomic_t shouldQuit = 0;
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* Signal handler: set the quit flag and close stdin so the main loop's
|
|
36
|
+
* blocking fgets() returns immediately. We can't safely call AVFoundation
|
|
37
|
+
* teardown from a signal handler, so we just unblock the loop and let
|
|
38
|
+
* main() do the [session stopRunning] / pclose on its way out.
|
|
39
|
+
* close() is async-signal-safe per POSIX.
|
|
40
|
+
*/
|
|
41
|
+
void handleSignal(int sig) {
|
|
42
|
+
shouldQuit = 1;
|
|
43
|
+
close(STDIN_FILENO);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* Enumerate all CoreMediaIO devices directly (bypasses AVFoundation filtering)
|
|
48
|
+
* and return their unique IDs. On modern macOS, AVCaptureDeviceDiscoverySession
|
|
49
|
+
* sometimes hides iOS devices that CMIO has registered; iterating CMIO directly
|
|
50
|
+
* is the most reliable way to discover them.
|
|
51
|
+
*/
|
|
52
|
+
static NSArray<NSString *> *enumerateCMIODeviceUIDs(void) {
|
|
53
|
+
NSMutableArray<NSString *> *uids = [NSMutableArray array];
|
|
54
|
+
|
|
55
|
+
CMIOObjectPropertyAddress addr = {
|
|
56
|
+
kCMIOHardwarePropertyDevices,
|
|
57
|
+
kCMIOObjectPropertyScopeGlobal,
|
|
58
|
+
kCMIOObjectPropertyElementMain
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
UInt32 dataSize = 0;
|
|
62
|
+
if (CMIOObjectGetPropertyDataSize(kCMIOObjectSystemObject, &addr, 0, NULL, &dataSize) != 0) {
|
|
63
|
+
return uids;
|
|
64
|
+
}
|
|
65
|
+
UInt32 count = dataSize / sizeof(CMIOObjectID);
|
|
66
|
+
if (count == 0) return uids;
|
|
67
|
+
|
|
68
|
+
CMIOObjectID *deviceIDs = (CMIOObjectID *)malloc(dataSize);
|
|
69
|
+
if (CMIOObjectGetPropertyData(kCMIOObjectSystemObject, &addr, 0, NULL, dataSize, &dataSize, deviceIDs) != 0) {
|
|
70
|
+
free(deviceIDs);
|
|
71
|
+
return uids;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (UInt32 i = 0; i < count; i++) {
|
|
75
|
+
CFStringRef uid = NULL;
|
|
76
|
+
UInt32 propSize = sizeof(uid);
|
|
77
|
+
CMIOObjectPropertyAddress uidAddr = {
|
|
78
|
+
kCMIODevicePropertyDeviceUID,
|
|
79
|
+
kCMIOObjectPropertyScopeGlobal,
|
|
80
|
+
kCMIOObjectPropertyElementMain
|
|
81
|
+
};
|
|
82
|
+
if (CMIOObjectGetPropertyData(deviceIDs[i], &uidAddr, 0, NULL, propSize, &propSize, &uid) == 0 && uid) {
|
|
83
|
+
[uids addObject:(__bridge NSString *)uid];
|
|
84
|
+
CFRelease(uid);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
free(deviceIDs);
|
|
88
|
+
return uids;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* Try to find an iOS device by checking every plausible AVCaptureDeviceType
|
|
93
|
+
* AND every UID reported by CMIO directly. Returns the first device whose
|
|
94
|
+
* media type includes muxed (audio+video) — the signature of an iOS device.
|
|
95
|
+
*/
|
|
96
|
+
static AVCaptureDevice *findIOSCaptureDevice(void) {
|
|
97
|
+
NSArray<AVCaptureDeviceType> *types = @[
|
|
98
|
+
AVCaptureDeviceTypeExternal,
|
|
99
|
+
AVCaptureDeviceTypeContinuityCamera,
|
|
100
|
+
AVCaptureDeviceTypeBuiltInWideAngleCamera,
|
|
101
|
+
AVCaptureDeviceTypeDeskViewCamera
|
|
102
|
+
];
|
|
103
|
+
NSArray<AVMediaType> *medias = @[AVMediaTypeMuxed, AVMediaTypeVideo];
|
|
104
|
+
|
|
105
|
+
for (AVCaptureDeviceType t in types) {
|
|
106
|
+
for (AVMediaType m in medias) {
|
|
107
|
+
AVCaptureDeviceDiscoverySession *ds = [AVCaptureDeviceDiscoverySession
|
|
108
|
+
discoverySessionWithDeviceTypes:@[t]
|
|
109
|
+
mediaType:m
|
|
110
|
+
position:AVCaptureDevicePositionUnspecified];
|
|
111
|
+
for (AVCaptureDevice *d in ds.devices) {
|
|
112
|
+
if ([d hasMediaType:AVMediaTypeMuxed]) {
|
|
113
|
+
return d;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Fallback: bridge each CMIO UID to AVCaptureDevice and pick a muxed one */
|
|
120
|
+
for (NSString *uid in enumerateCMIODeviceUIDs()) {
|
|
121
|
+
AVCaptureDevice *d = [AVCaptureDevice deviceWithUniqueID:uid];
|
|
122
|
+
if (d && [d hasMediaType:AVMediaTypeMuxed]) {
|
|
123
|
+
fprintf(stderr, "Found iOS device via CMIO bridge: %s (uid=%s)\n",
|
|
124
|
+
[[d localizedName] UTF8String], [uid UTF8String]);
|
|
125
|
+
return d;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return nil;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@interface Recorder : NSObject <AVCaptureVideoDataOutputSampleBufferDelegate>
|
|
133
|
+
@property (nonatomic, strong) AVCaptureSession *session;
|
|
134
|
+
@property (nonatomic, assign) int frameCount;
|
|
135
|
+
@property (nonatomic, assign) int width;
|
|
136
|
+
@property (nonatomic, assign) int height;
|
|
137
|
+
@property (nonatomic, assign) BOOL isRecording;
|
|
138
|
+
@property (nonatomic, assign) FILE *ffmpegPipe;
|
|
139
|
+
@property (nonatomic, assign) int framerate;
|
|
140
|
+
@end
|
|
141
|
+
|
|
142
|
+
@implementation Recorder
|
|
143
|
+
|
|
144
|
+
- (void)captureOutput:(AVCaptureOutput *)output
|
|
145
|
+
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
146
|
+
fromConnection:(AVCaptureConnection *)connection {
|
|
147
|
+
|
|
148
|
+
CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
|
|
149
|
+
if (!imageBuffer) return;
|
|
150
|
+
|
|
151
|
+
CVPixelBufferLockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly);
|
|
152
|
+
|
|
153
|
+
size_t width = CVPixelBufferGetWidth(imageBuffer);
|
|
154
|
+
size_t height = CVPixelBufferGetHeight(imageBuffer);
|
|
155
|
+
|
|
156
|
+
/* Always update dimensions from incoming frames */
|
|
157
|
+
if (self.width == 0) {
|
|
158
|
+
self.width = (int)width;
|
|
159
|
+
self.height = (int)height;
|
|
160
|
+
fprintf(stderr, "Device resolution: %dx%d\n", self.width, self.height);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Only write frames when recording */
|
|
164
|
+
if (self.isRecording && self.ffmpegPipe) {
|
|
165
|
+
size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer);
|
|
166
|
+
void *baseAddress = CVPixelBufferGetBaseAddress(imageBuffer);
|
|
167
|
+
if (baseAddress) {
|
|
168
|
+
for (size_t row = 0; row < height; row++) {
|
|
169
|
+
fwrite((uint8_t *)baseAddress + row * bytesPerRow, 1, width * 4, self.ffmpegPipe);
|
|
170
|
+
}
|
|
171
|
+
self.frameCount++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
CVPixelBufferUnlockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
- (BOOL)startRecordingToFile:(NSString *)path {
|
|
179
|
+
if (self.isRecording) {
|
|
180
|
+
[self stopRecording];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
self.frameCount = 0;
|
|
184
|
+
|
|
185
|
+
/* Wait for dimensions if we haven't seen a frame yet */
|
|
186
|
+
if (self.width == 0) {
|
|
187
|
+
fprintf(stderr, "Waiting for first frame...\n");
|
|
188
|
+
for (int i = 0; i < 100 && self.width == 0; i++) {
|
|
189
|
+
[NSThread sleepForTimeInterval:0.1];
|
|
190
|
+
}
|
|
191
|
+
if (self.width == 0) {
|
|
192
|
+
fprintf(stderr, "Could not determine video dimensions\n");
|
|
193
|
+
return NO;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
int evenWidth = (self.width / 2) * 2;
|
|
198
|
+
int evenHeight = (self.height / 2) * 2;
|
|
199
|
+
|
|
200
|
+
NSString *cmd = [NSString stringWithFormat:
|
|
201
|
+
@"ffmpeg -f rawvideo -pix_fmt bgra -s %dx%d -r %d -i - "
|
|
202
|
+
@"-vf 'scale=%d:%d' "
|
|
203
|
+
@"-c:v libx264 -preset ultrafast -pix_fmt yuv420p -y '%@' 2>/dev/null",
|
|
204
|
+
self.width, self.height, self.framerate,
|
|
205
|
+
evenWidth, evenHeight,
|
|
206
|
+
path];
|
|
207
|
+
|
|
208
|
+
self.ffmpegPipe = popen([cmd UTF8String], "w");
|
|
209
|
+
if (!self.ffmpegPipe) {
|
|
210
|
+
fprintf(stderr, "Failed to start ffmpeg\n");
|
|
211
|
+
return NO;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
self.isRecording = YES;
|
|
215
|
+
self.frameCount = 0;
|
|
216
|
+
return YES;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
- (void)stopRecording {
|
|
220
|
+
self.isRecording = NO;
|
|
221
|
+
|
|
222
|
+
if (self.ffmpegPipe) {
|
|
223
|
+
pclose(self.ffmpegPipe);
|
|
224
|
+
self.ffmpegPipe = NULL;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
fprintf(stderr, "Recorded %d frames\n", self.frameCount);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
@end
|
|
231
|
+
|
|
232
|
+
int main(int argc, const char *argv[]) {
|
|
233
|
+
@autoreleasepool {
|
|
234
|
+
signal(SIGINT, handleSignal);
|
|
235
|
+
signal(SIGTERM, handleSignal);
|
|
236
|
+
|
|
237
|
+
int framerate = 30;
|
|
238
|
+
for (int i = 1; i < argc; i++) {
|
|
239
|
+
if (strcmp(argv[i], "-r") == 0 && i + 1 < argc) {
|
|
240
|
+
framerate = atoi(argv[++i]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* Step 1: Enable screen capture devices */
|
|
245
|
+
CMIOObjectPropertyAddress prop = {
|
|
246
|
+
kCMIOHardwarePropertyAllowScreenCaptureDevices,
|
|
247
|
+
kCMIOObjectPropertyScopeGlobal,
|
|
248
|
+
kCMIOObjectPropertyElementMain
|
|
249
|
+
};
|
|
250
|
+
UInt32 allow = 1;
|
|
251
|
+
CMIOObjectSetPropertyData(kCMIOObjectSystemObject, &prop, 0, NULL, sizeof(allow), &allow);
|
|
252
|
+
|
|
253
|
+
/* Step 2: Warm up AVCaptureDevice */
|
|
254
|
+
#pragma clang diagnostic push
|
|
255
|
+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
|
256
|
+
[AVCaptureDevice devices];
|
|
257
|
+
#pragma clang diagnostic pop
|
|
258
|
+
[NSThread sleepForTimeInterval:1.0];
|
|
259
|
+
|
|
260
|
+
/* Log what CMIO sees directly — useful when discovery fails */
|
|
261
|
+
NSArray<NSString *> *cmioUIDs = enumerateCMIODeviceUIDs();
|
|
262
|
+
fprintf(stderr, "CMIO reports %lu device(s)\n", (unsigned long)cmioUIDs.count);
|
|
263
|
+
for (NSString *uid in cmioUIDs) {
|
|
264
|
+
fprintf(stderr, " CMIO UID: %s\n", [uid UTF8String]);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/* Step 3: Find iOS device — multi-type filter + CMIO bridge fallback */
|
|
268
|
+
AVCaptureDevice *device = nil;
|
|
269
|
+
for (int attempt = 0; attempt < 20 && !device; attempt++) {
|
|
270
|
+
device = findIOSCaptureDevice();
|
|
271
|
+
if (!device) [NSThread sleepForTimeInterval:0.5];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!device) {
|
|
275
|
+
fprintf(stdout, "ERROR No iOS device found\n");
|
|
276
|
+
fflush(stdout);
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fprintf(stderr, "Found device: %s\n", [[device localizedName] UTF8String]);
|
|
281
|
+
|
|
282
|
+
/* Step 4: Set up capture session */
|
|
283
|
+
AVCaptureSession *session = [[AVCaptureSession alloc] init];
|
|
284
|
+
NSError *error = nil;
|
|
285
|
+
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error];
|
|
286
|
+
if (!input) {
|
|
287
|
+
fprintf(stdout, "ERROR Failed to create capture input\n");
|
|
288
|
+
fflush(stdout);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
[session addInput:input];
|
|
292
|
+
|
|
293
|
+
AVCaptureVideoDataOutput *videoOutput = [[AVCaptureVideoDataOutput alloc] init];
|
|
294
|
+
videoOutput.videoSettings = @{
|
|
295
|
+
(NSString *)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
Recorder *recorder = [[Recorder alloc] init];
|
|
299
|
+
recorder.framerate = framerate;
|
|
300
|
+
|
|
301
|
+
dispatch_queue_t queue = dispatch_queue_create("capture", DISPATCH_QUEUE_SERIAL);
|
|
302
|
+
[videoOutput setSampleBufferDelegate:recorder queue:queue];
|
|
303
|
+
[session addOutput:videoOutput];
|
|
304
|
+
|
|
305
|
+
[session startRunning];
|
|
306
|
+
|
|
307
|
+
/* Signal ready */
|
|
308
|
+
fprintf(stdout, "READY\n");
|
|
309
|
+
fflush(stdout);
|
|
310
|
+
|
|
311
|
+
/* Step 5: Read commands from stdin */
|
|
312
|
+
char line[4096];
|
|
313
|
+
while (!shouldQuit && fgets(line, sizeof(line), stdin)) {
|
|
314
|
+
/* Remove newline */
|
|
315
|
+
size_t len = strlen(line);
|
|
316
|
+
if (len > 0 && line[len-1] == '\n') line[len-1] = '\0';
|
|
317
|
+
|
|
318
|
+
if (strncmp(line, "START ", 6) == 0) {
|
|
319
|
+
NSString *path = [NSString stringWithUTF8String:line + 6];
|
|
320
|
+
if ([recorder startRecordingToFile:path]) {
|
|
321
|
+
fprintf(stdout, "RECORDING\n");
|
|
322
|
+
} else {
|
|
323
|
+
fprintf(stdout, "ERROR Failed to start recording\n");
|
|
324
|
+
}
|
|
325
|
+
fflush(stdout);
|
|
326
|
+
} else if (strcmp(line, "STOP") == 0) {
|
|
327
|
+
[recorder stopRecording];
|
|
328
|
+
fprintf(stdout, "STOPPED\n");
|
|
329
|
+
fflush(stdout);
|
|
330
|
+
} else if (strcmp(line, "QUIT") == 0) {
|
|
331
|
+
[recorder stopRecording];
|
|
332
|
+
shouldQuit = YES;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
[recorder stopRecording];
|
|
337
|
+
[session stopRunning];
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/* global WebSocket */
|
|
2
|
+
import { execa } from 'execa';
|
|
3
|
+
import { getLogger } from '@sitespeed.io/log';
|
|
4
|
+
|
|
5
|
+
const log = getLogger('browsertime.safari.inspector');
|
|
6
|
+
|
|
7
|
+
export class SafariInspectorClient {
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.safariOptions = options.safari || {};
|
|
11
|
+
this.port = options.iwdpPort || 9222;
|
|
12
|
+
this.connections = [];
|
|
13
|
+
this.targetId = undefined;
|
|
14
|
+
this.targetWs = undefined; // The WebSocket that owns the active target
|
|
15
|
+
this.pinnedWs = undefined; // When set, only this ws may set targetId
|
|
16
|
+
this.messageId = 0;
|
|
17
|
+
this.pendingMessages = new Map();
|
|
18
|
+
this.eventListeners = new Map();
|
|
19
|
+
this.iwdpProcess = undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async setup(targetMatcher) {
|
|
23
|
+
await this._verifyIwdpInstalled();
|
|
24
|
+
await this._startIwdp();
|
|
25
|
+
await this._waitForIwdp();
|
|
26
|
+
await this._connectWebSockets(targetMatcher);
|
|
27
|
+
await this._waitForTarget();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async _verifyIwdpInstalled() {
|
|
31
|
+
try {
|
|
32
|
+
await execa('which', ['ios_webkit_debug_proxy']);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'ios_webkit_debug_proxy is not installed. Install it with: brew install ios-webkit-debug-proxy'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async _startIwdp() {
|
|
41
|
+
const args = ['-F'];
|
|
42
|
+
if (this.safariOptions.deviceUDID) {
|
|
43
|
+
args.push('-u', this.safariOptions.deviceUDID);
|
|
44
|
+
}
|
|
45
|
+
log.debug('Starting ios_webkit_debug_proxy on port %d', this.port);
|
|
46
|
+
this.iwdpProcess = execa('ios_webkit_debug_proxy', args, {
|
|
47
|
+
stdio: 'ignore'
|
|
48
|
+
});
|
|
49
|
+
this.iwdpProcess.catch(error => {
|
|
50
|
+
if (!error.isCanceled) {
|
|
51
|
+
log.error('ios_webkit_debug_proxy failed: %s', error.message);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async _waitForIwdp() {
|
|
57
|
+
const maxRetries = 20;
|
|
58
|
+
const retryDelay = 500;
|
|
59
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(`http://localhost:${this.port}/json`);
|
|
62
|
+
if (response.ok) {
|
|
63
|
+
log.debug('ios_webkit_debug_proxy is ready');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Not ready yet
|
|
68
|
+
}
|
|
69
|
+
await new Promise(r => setTimeout(r, retryDelay));
|
|
70
|
+
}
|
|
71
|
+
throw new Error(
|
|
72
|
+
`ios_webkit_debug_proxy did not start within ${(maxRetries * retryDelay) / 1000} seconds`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _connectWebSockets(targetMatcher) {
|
|
77
|
+
const response = await fetch(`http://localhost:${this.port}/json`);
|
|
78
|
+
const pages = await response.json();
|
|
79
|
+
|
|
80
|
+
for (const page of pages) {
|
|
81
|
+
if (!page.webSocketDebuggerUrl) continue;
|
|
82
|
+
try {
|
|
83
|
+
const ws = await new Promise((resolve, reject) => {
|
|
84
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
85
|
+
socket.addEventListener('open', () => resolve(socket));
|
|
86
|
+
socket.addEventListener('error', () =>
|
|
87
|
+
reject(new Error('WebSocket error'))
|
|
88
|
+
);
|
|
89
|
+
setTimeout(() => reject(new Error('timeout')), 5000);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Pin BEFORE the message listener is attached so the very first
|
|
93
|
+
// Target.targetCreated from any other tab cannot set targetId.
|
|
94
|
+
if (targetMatcher && targetMatcher(page)) {
|
|
95
|
+
this.pinnedWs = ws;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ws.addEventListener('message', event => {
|
|
99
|
+
this._handleMessage(ws, JSON.parse(event.data));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
this.connections.push({ ws, page });
|
|
103
|
+
log.debug('Connected to page: %s', page.title || page.url || 'unknown');
|
|
104
|
+
} catch {
|
|
105
|
+
// Skip pages we can't connect to
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (this.connections.length === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
'No inspectable Safari pages found via ios_webkit_debug_proxy'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (targetMatcher && !this.pinnedWs) {
|
|
116
|
+
log.warn(
|
|
117
|
+
'Could not identify the WebDriver Safari tab via iWDP — HAR may be empty if other tabs are open on the device'
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
log.debug('Connected to %d inspectable pages', this.connections.length);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async _waitForTarget() {
|
|
124
|
+
// Wait for targetCreated events
|
|
125
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
126
|
+
|
|
127
|
+
if (this.targetId) {
|
|
128
|
+
log.debug('Network and Page enabled on target %s', this.targetId);
|
|
129
|
+
} else {
|
|
130
|
+
log.debug(
|
|
131
|
+
'No page target found yet — will enable Network when a target appears'
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_handleMessage(ws, msg) {
|
|
137
|
+
// Handle Target.targetCreated — auto-enable Network on new page targets
|
|
138
|
+
if (msg.method === 'Target.targetCreated') {
|
|
139
|
+
const target = msg.params?.targetInfo;
|
|
140
|
+
if (target?.type === 'page') {
|
|
141
|
+
// If we've pinned the WebDriver-controlled tab, ignore targetCreated
|
|
142
|
+
// from unrelated tabs so they can't clobber the active target.
|
|
143
|
+
if (this.pinnedWs && ws !== this.pinnedWs) return;
|
|
144
|
+
this.targetId = target.targetId;
|
|
145
|
+
this.targetWs = ws; // Remember which WebSocket owns this target
|
|
146
|
+
log.debug('Found page target: %s', this.targetId);
|
|
147
|
+
// Enable Network and Page on this target immediately
|
|
148
|
+
this._sendOnWs(ws, 'Target.sendMessageToTarget', {
|
|
149
|
+
targetId: target.targetId,
|
|
150
|
+
message: JSON.stringify({
|
|
151
|
+
id: ++this.messageId,
|
|
152
|
+
method: 'Network.enable',
|
|
153
|
+
params: {}
|
|
154
|
+
})
|
|
155
|
+
});
|
|
156
|
+
this._sendOnWs(ws, 'Target.sendMessageToTarget', {
|
|
157
|
+
targetId: target.targetId,
|
|
158
|
+
message: JSON.stringify({
|
|
159
|
+
id: ++this.messageId,
|
|
160
|
+
method: 'Page.enable',
|
|
161
|
+
params: {}
|
|
162
|
+
})
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Unwrap Target.dispatchMessageFromTarget — only from the active target
|
|
168
|
+
if (msg.method === 'Target.dispatchMessageFromTarget') {
|
|
169
|
+
try {
|
|
170
|
+
const innerMsg = JSON.parse(msg.params.message);
|
|
171
|
+
// Only dispatch events from the active target to avoid cross-tab noise
|
|
172
|
+
const fromTargetId = msg.params.targetId;
|
|
173
|
+
if (innerMsg.method && fromTargetId === this.targetId) {
|
|
174
|
+
const listeners = this.eventListeners.get(innerMsg.method) || [];
|
|
175
|
+
for (const cb of listeners) {
|
|
176
|
+
cb(innerMsg.params);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Resolve pending commands
|
|
180
|
+
if (
|
|
181
|
+
innerMsg.id !== undefined &&
|
|
182
|
+
this.pendingMessages.has(innerMsg.id)
|
|
183
|
+
) {
|
|
184
|
+
const { resolve } = this.pendingMessages.get(innerMsg.id);
|
|
185
|
+
this.pendingMessages.delete(innerMsg.id);
|
|
186
|
+
resolve(innerMsg);
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Not valid JSON
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Resolve top-level responses
|
|
194
|
+
if (msg.id !== undefined && this.pendingMessages.has(msg.id)) {
|
|
195
|
+
const { resolve } = this.pendingMessages.get(msg.id);
|
|
196
|
+
this.pendingMessages.delete(msg.id);
|
|
197
|
+
resolve(msg);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Send a command to the active page target via Target.sendMessageToTarget.
|
|
203
|
+
*/
|
|
204
|
+
async send(method, params = {}) {
|
|
205
|
+
if (!this.targetId || !this.targetWs) {
|
|
206
|
+
throw new Error('No target available for sending commands');
|
|
207
|
+
}
|
|
208
|
+
const innerId = ++this.messageId;
|
|
209
|
+
const innerMsg = JSON.stringify({ id: innerId, method, params });
|
|
210
|
+
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
this.pendingMessages.set(innerId, { resolve, reject });
|
|
213
|
+
try {
|
|
214
|
+
const outerId = ++this.messageId;
|
|
215
|
+
this.targetWs.send(
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
id: outerId,
|
|
218
|
+
method: 'Target.sendMessageToTarget',
|
|
219
|
+
params: { targetId: this.targetId, message: innerMsg }
|
|
220
|
+
})
|
|
221
|
+
);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
this.pendingMessages.delete(innerId);
|
|
224
|
+
reject(error);
|
|
225
|
+
}
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
if (this.pendingMessages.has(innerId)) {
|
|
228
|
+
this.pendingMessages.delete(innerId);
|
|
229
|
+
resolve({ error: 'timeout' });
|
|
230
|
+
}
|
|
231
|
+
}, 10_000);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Fire-and-forget send on a specific WebSocket.
|
|
237
|
+
*/
|
|
238
|
+
_sendOnWs(ws, method, params) {
|
|
239
|
+
try {
|
|
240
|
+
ws.send(JSON.stringify({ id: ++this.messageId, method, params }));
|
|
241
|
+
} catch (error) {
|
|
242
|
+
log.debug('Send error: %s', error.message);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
on(eventName, callback) {
|
|
247
|
+
if (!this.eventListeners.has(eventName)) {
|
|
248
|
+
this.eventListeners.set(eventName, []);
|
|
249
|
+
}
|
|
250
|
+
this.eventListeners.get(eventName).push(callback);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
removeAllListeners() {
|
|
254
|
+
this.eventListeners.clear();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Reconnect to iwdp to discover new pages that appeared after initial setup.
|
|
259
|
+
* This is needed because safaridriver may create new tabs that weren't
|
|
260
|
+
* in the original page list.
|
|
261
|
+
*/
|
|
262
|
+
async reconnect() {
|
|
263
|
+
const response = await fetch(`http://localhost:${this.port}/json`);
|
|
264
|
+
const pages = await response.json();
|
|
265
|
+
|
|
266
|
+
// Find pages we're not already connected to
|
|
267
|
+
const connectedUrls = new Set(
|
|
268
|
+
this.connections.map(c => c.page.webSocketDebuggerUrl)
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
for (const page of pages) {
|
|
272
|
+
if (!page.webSocketDebuggerUrl) continue;
|
|
273
|
+
if (connectedUrls.has(page.webSocketDebuggerUrl)) continue;
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const ws = await new Promise((resolve, reject) => {
|
|
277
|
+
const socket = new WebSocket(page.webSocketDebuggerUrl);
|
|
278
|
+
socket.addEventListener('open', () => resolve(socket));
|
|
279
|
+
socket.addEventListener('error', () =>
|
|
280
|
+
reject(new Error('WebSocket error'))
|
|
281
|
+
);
|
|
282
|
+
setTimeout(() => reject(new Error('timeout')), 3000);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
ws.addEventListener('message', event => {
|
|
286
|
+
this._handleMessage(ws, JSON.parse(event.data));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
this.connections.push({ ws, page });
|
|
290
|
+
log.debug(
|
|
291
|
+
'Reconnected to new page: %s (%s)',
|
|
292
|
+
page.title || 'untitled',
|
|
293
|
+
page.url || 'no url'
|
|
294
|
+
);
|
|
295
|
+
} catch {
|
|
296
|
+
// Skip
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async close() {
|
|
302
|
+
for (const conn of this.connections) {
|
|
303
|
+
try {
|
|
304
|
+
conn.ws.close();
|
|
305
|
+
} catch {
|
|
306
|
+
// Ignore close errors
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
this.connections = [];
|
|
310
|
+
this.targetWs = undefined;
|
|
311
|
+
this.pinnedWs = undefined;
|
|
312
|
+
if (this.iwdpProcess) {
|
|
313
|
+
try {
|
|
314
|
+
this.iwdpProcess.kill();
|
|
315
|
+
} catch {
|
|
316
|
+
// Ignore kill errors
|
|
317
|
+
}
|
|
318
|
+
this.iwdpProcess = undefined;
|
|
319
|
+
}
|
|
320
|
+
log.debug('Safari inspector client closed');
|
|
321
|
+
}
|
|
322
|
+
}
|