@expo/build-tools 20.4.0 → 21.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/dist/android/gradle.d.ts +5 -0
- package/dist/android/gradle.js +13 -33
- package/dist/common/installDependencies.js +21 -18
- package/dist/common/projectSources.js +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/context.js +31 -1
- package/dist/ios/fastlane.d.ts +1 -1
- package/dist/steps/easFunctions.js +18 -0
- package/dist/steps/functions/capturePosthogEvent.d.ts +2 -0
- package/dist/steps/functions/capturePosthogEvent.js +79 -0
- package/dist/steps/functions/createPosthogAnnotation.d.ts +2 -0
- package/dist/steps/functions/createPosthogAnnotation.js +75 -0
- package/dist/steps/functions/createSubmissionEntity.js +25 -1
- package/dist/steps/functions/downloadArtifact.js +13 -3
- package/dist/steps/functions/finishIosSimulatorRecordings.d.ts +2 -0
- package/dist/steps/functions/finishIosSimulatorRecordings.js +24 -0
- package/dist/steps/functions/repack.js +12 -7
- package/dist/steps/functions/restoreCache.js +2 -1
- package/dist/steps/functions/rolloutPosthogFlag.d.ts +2 -0
- package/dist/steps/functions/rolloutPosthogFlag.js +208 -0
- package/dist/steps/functions/saveCache.js +3 -2
- package/dist/steps/functions/startAgentDeviceRemoteSession.js +21 -8
- package/dist/steps/functions/startArgentRemoteSession.d.ts +14 -0
- package/dist/steps/functions/startArgentRemoteSession.js +105 -44
- package/dist/steps/functions/startIosSimulator.js +19 -0
- package/dist/steps/functions/startIosSimulatorRecordings.d.ts +2 -0
- package/dist/steps/functions/startIosSimulatorRecordings.js +20 -0
- package/dist/steps/functions/startServeSimRemoteSession.js +7 -5
- package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.d.ts +3 -0
- package/dist/steps/functions/uploadDeviceRunSessionScreenRecordings.js +91 -0
- package/dist/steps/functions/uploadPosthogSourcemaps.d.ts +2 -0
- package/dist/steps/functions/uploadPosthogSourcemaps.js +83 -0
- package/dist/steps/functions/waitForPosthogMetric.d.ts +2 -0
- package/dist/steps/functions/waitForPosthogMetric.js +135 -0
- package/dist/steps/functions/waitForPosthogQuery.d.ts +2 -0
- package/dist/steps/functions/waitForPosthogQuery.js +95 -0
- package/dist/steps/utils/IosSimulatorRecordingUtils.d.ts +17 -0
- package/dist/steps/utils/IosSimulatorRecordingUtils.js +218 -0
- package/dist/steps/utils/PosthogClient.d.ts +46 -0
- package/dist/steps/utils/PosthogClient.js +156 -0
- package/dist/steps/utils/PosthogUtils.d.ts +41 -0
- package/dist/steps/utils/PosthogUtils.js +65 -0
- package/dist/steps/utils/agentDeviceArtifacts.d.ts +27 -0
- package/dist/steps/utils/agentDeviceArtifacts.js +133 -0
- package/dist/steps/utils/android/gradle.d.ts +5 -0
- package/dist/steps/utils/android/gradle.js +13 -62
- package/dist/steps/utils/argentArtifacts.d.ts +5 -3
- package/dist/steps/utils/argentArtifacts.js +95 -23
- package/dist/steps/utils/deviceRunSessionArtifacts.d.ts +3 -1
- package/dist/steps/utils/deviceRunSessionArtifacts.js +6 -2
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +8 -0
- package/dist/steps/utils/remoteDeviceRunSession.js +62 -1
- package/dist/utils/AndroidEmulatorUtils.js +3 -0
- package/dist/utils/IosSimulatorUtils.d.ts +5 -0
- package/dist/utils/IosSimulatorUtils.js +81 -2
- package/dist/utils/expoUpdates.js +6 -1
- package/dist/utils/expoUpdatesEmbedded.js +4 -0
- package/dist/utils/processes.d.ts +1 -0
- package/dist/utils/processes.js +23 -0
- package/package.json +9 -8
- package/resources/record-sim/Package.swift +28 -0
- package/resources/record-sim/README.md +79 -0
- package/resources/record-sim/Sources/RecordSim/FramebufferDisplaySource.swift +192 -0
- package/resources/record-sim/Sources/RecordSim/PrivateSimulatorServices.swift +96 -0
- package/resources/record-sim/Sources/RecordSim/RecordingModels.swift +70 -0
- package/resources/record-sim/Sources/RecordSim/RecordingOutputWriter.swift +136 -0
- package/resources/record-sim/Sources/RecordSim/SimulatorRecorder.swift +632 -0
- package/resources/record-sim/Sources/RecordSim/Utilities.swift +60 -0
- package/resources/record-sim/Sources/record-sim/main.swift +143 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# RecordSim
|
|
2
|
+
|
|
3
|
+
Small Swift library for non-exclusive iOS Simulator screen recording.
|
|
4
|
+
|
|
5
|
+
It implements one recording path:
|
|
6
|
+
|
|
7
|
+
1. Listen to SimRenderServer framebuffer callbacks.
|
|
8
|
+
2. Copy the live `IOSurface` immediately into an owned `CVPixelBuffer`.
|
|
9
|
+
3. Feed one continuous `AVAssetWriter`.
|
|
10
|
+
4. Emit Apple HLS/CMAF-style fragmented MP4 segments.
|
|
11
|
+
|
|
12
|
+
The output is suitable for uploading while recording. `session.json` contains
|
|
13
|
+
the ordered segment metadata needed to build a playlist later.
|
|
14
|
+
|
|
15
|
+
## Output
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
session/
|
|
19
|
+
init.mp4
|
|
20
|
+
session.json
|
|
21
|
+
segments/
|
|
22
|
+
segment-000000.m4s
|
|
23
|
+
segment-000001.m4s
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`init.mp4` is the fMP4 initialization segment. Individual `.m4s` files are not
|
|
27
|
+
standalone MP4 files. `session.json` stores the wall-clock timestamp for the
|
|
28
|
+
first video frame plus the metadata needed to build a playlist: HLS version,
|
|
29
|
+
target duration, media sequence, `initSegment`, and the ordered `segments` array
|
|
30
|
+
with file path and duration for each media segment.
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
swift run record-sim \
|
|
36
|
+
--udid <BOOTED_SIMULATOR_UDID> \
|
|
37
|
+
--output /tmp/sim-recording \
|
|
38
|
+
--segment-duration 120 \
|
|
39
|
+
--fps 30 \
|
|
40
|
+
--bitrate 30000000 \
|
|
41
|
+
--codec h264
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For sparse 2h sessions, `--segment-duration 120` is a reasonable default: about
|
|
45
|
+
60 media objects plus the init segment. The recorder appends a single duplicate
|
|
46
|
+
hold frame near each segment boundary when the simulator is idle, so long idle
|
|
47
|
+
periods still produce uploadable segments without encoding idle frames at 60fps.
|
|
48
|
+
Use `--segment-duration 0` to write one continuous `recording.mp4` instead of
|
|
49
|
+
`init.mp4` plus media segments.
|
|
50
|
+
|
|
51
|
+
Frame timestamps use monotonic host time, not wall-clock time. The recorder also
|
|
52
|
+
probes the framebuffer seed once per second; if the surface changes without
|
|
53
|
+
callbacks for 5 seconds, it captures that frame and rewires the private callback
|
|
54
|
+
registration.
|
|
55
|
+
|
|
56
|
+
## Library
|
|
57
|
+
|
|
58
|
+
```swift
|
|
59
|
+
let recorder = SimulatorRecorder(
|
|
60
|
+
configuration: SimulatorRecordingConfiguration(
|
|
61
|
+
deviceUDID: udid,
|
|
62
|
+
outputDirectory: sessionDirectory,
|
|
63
|
+
segmentDuration: 120
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
recorder.onSegment = { segment in
|
|
68
|
+
// Enqueue upload of segment.url.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try recorder.start()
|
|
72
|
+
try recorder.waitUntilFirstFrame()
|
|
73
|
+
// ...
|
|
74
|
+
let manifest = try recorder.stop()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`session.json` contains the precise wall-clock time for video PTS zero. In
|
|
78
|
+
segmented mode it also contains the init segment and ordered media segment
|
|
79
|
+
file/duration entries; in single-file mode it points at `recording.mp4`.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import IOSurface
|
|
3
|
+
import ObjectiveC
|
|
4
|
+
|
|
5
|
+
final class FramebufferDisplaySource {
|
|
6
|
+
struct SurfaceSnapshot {
|
|
7
|
+
let surface: IOSurface
|
|
8
|
+
let width: Int
|
|
9
|
+
let height: Int
|
|
10
|
+
let seed: UInt32
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private let deviceUDID: String
|
|
14
|
+
private let callbackQueue: DispatchQueue
|
|
15
|
+
private let onFrame: () -> Void
|
|
16
|
+
private let onSurfaceChange: () -> Void
|
|
17
|
+
private var descriptors: [NSObject] = []
|
|
18
|
+
private var callbackUUIDs: [ObjectIdentifier: NSUUID] = [:]
|
|
19
|
+
private var retainedBlocks: [AnyObject] = []
|
|
20
|
+
private var ioClient: NSObject?
|
|
21
|
+
|
|
22
|
+
init(
|
|
23
|
+
deviceUDID: String,
|
|
24
|
+
callbackQueue: DispatchQueue,
|
|
25
|
+
onFrame: @escaping () -> Void,
|
|
26
|
+
onSurfaceChange: @escaping () -> Void
|
|
27
|
+
) {
|
|
28
|
+
self.deviceUDID = deviceUDID
|
|
29
|
+
self.callbackQueue = callbackQueue
|
|
30
|
+
self.onFrame = onFrame
|
|
31
|
+
self.onSurfaceChange = onSurfaceChange
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func start() throws {
|
|
35
|
+
try PrivateSimulatorFrameworks.load()
|
|
36
|
+
guard let device = SimulatorDeviceLookup.find(udid: deviceUDID) else {
|
|
37
|
+
throw RecorderError.make(1, "Simulator \(deviceUDID) not found")
|
|
38
|
+
}
|
|
39
|
+
let state = device.value(forKey: "stateString") as? String ?? "unknown"
|
|
40
|
+
guard state == "Booted" else {
|
|
41
|
+
throw RecorderError.make(2, "Simulator \(deviceUDID) is not booted (state: \(state))")
|
|
42
|
+
}
|
|
43
|
+
guard
|
|
44
|
+
let io = device.perform(NSSelectorFromString("io"))?.takeUnretainedValue() as? NSObject
|
|
45
|
+
else {
|
|
46
|
+
throw RecorderError.make(3, "Failed to get simulator IO client")
|
|
47
|
+
}
|
|
48
|
+
ioClient = io
|
|
49
|
+
try wireUpFramebuffer()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func stop() {
|
|
53
|
+
unregisterCallbacks()
|
|
54
|
+
descriptors.removeAll()
|
|
55
|
+
retainedBlocks.removeAll()
|
|
56
|
+
ioClient = nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func surfaceSnapshot() -> SurfaceSnapshot? {
|
|
60
|
+
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
|
61
|
+
var bestSnapshot: SurfaceSnapshot?
|
|
62
|
+
var bestArea = 0
|
|
63
|
+
for descriptor in descriptors {
|
|
64
|
+
guard let surfaceObject = descriptor.perform(surfaceSelector)?.takeUnretainedValue()
|
|
65
|
+
else {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
let surface = unsafeBitCast(surfaceObject, to: IOSurface.self)
|
|
69
|
+
let width = IOSurfaceGetWidth(surface)
|
|
70
|
+
let height = IOSurfaceGetHeight(surface)
|
|
71
|
+
let area = width * height
|
|
72
|
+
if area > bestArea {
|
|
73
|
+
bestSnapshot = SurfaceSnapshot(
|
|
74
|
+
surface: surface,
|
|
75
|
+
width: width,
|
|
76
|
+
height: height,
|
|
77
|
+
seed: IOSurfaceGetSeed(surface)
|
|
78
|
+
)
|
|
79
|
+
bestArea = area
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return bestSnapshot
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func rewireFramebuffer() throws {
|
|
86
|
+
try wireUpFramebuffer()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func wireUpFramebuffer() throws {
|
|
90
|
+
guard let io = ioClient else {
|
|
91
|
+
throw RecorderError.make(3, "No simulator IO client")
|
|
92
|
+
}
|
|
93
|
+
io.perform(NSSelectorFromString("updateIOPorts"))
|
|
94
|
+
let nextDescriptors = try findFramebufferDescriptors(io: io)
|
|
95
|
+
unregisterCallbacks()
|
|
96
|
+
descriptors = nextDescriptors
|
|
97
|
+
retainedBlocks.removeAll()
|
|
98
|
+
do {
|
|
99
|
+
for descriptor in descriptors {
|
|
100
|
+
try registerCallbacks(descriptor: descriptor)
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
unregisterCallbacks()
|
|
104
|
+
descriptors.removeAll()
|
|
105
|
+
retainedBlocks.removeAll()
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func findFramebufferDescriptors(io: NSObject) throws -> [NSObject] {
|
|
111
|
+
guard let ports = io.value(forKey: "deviceIOPorts") as? [NSObject] else {
|
|
112
|
+
throw RecorderError.make(4, "Failed to read simulator IO ports")
|
|
113
|
+
}
|
|
114
|
+
let portIdentifierSelector = NSSelectorFromString("portIdentifier")
|
|
115
|
+
let descriptorSelector = NSSelectorFromString("descriptor")
|
|
116
|
+
let surfaceSelector = NSSelectorFromString("framebufferSurface")
|
|
117
|
+
|
|
118
|
+
var candidates: [NSObject] = []
|
|
119
|
+
for port in ports {
|
|
120
|
+
guard port.responds(to: portIdentifierSelector),
|
|
121
|
+
let portIdentifier = port.perform(portIdentifierSelector)?.takeUnretainedValue(),
|
|
122
|
+
"\(portIdentifier)" == "com.apple.framebuffer.display",
|
|
123
|
+
port.responds(to: descriptorSelector),
|
|
124
|
+
let descriptor = port.perform(descriptorSelector)?.takeUnretainedValue()
|
|
125
|
+
as? NSObject,
|
|
126
|
+
descriptor.responds(to: surfaceSelector)
|
|
127
|
+
else {
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
candidates.append(descriptor)
|
|
131
|
+
}
|
|
132
|
+
if candidates.isEmpty {
|
|
133
|
+
throw RecorderError.make(5, "No framebuffer display descriptor found")
|
|
134
|
+
}
|
|
135
|
+
return candidates
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func registerCallbacks(descriptor: NSObject) throws {
|
|
139
|
+
let selector = NSSelectorFromString(
|
|
140
|
+
"registerScreenCallbacksWithUUID:callbackQueue:frameCallback:surfacesChangedCallback:propertiesChangedCallback:"
|
|
141
|
+
)
|
|
142
|
+
guard descriptor.responds(to: selector) else {
|
|
143
|
+
throw RecorderError.make(6, "Framebuffer descriptor does not support screen callbacks")
|
|
144
|
+
}
|
|
145
|
+
guard let msgSendPointer = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "objc_msgSend")
|
|
146
|
+
else {
|
|
147
|
+
throw RecorderError.make(7, "objc_msgSend not found")
|
|
148
|
+
}
|
|
149
|
+
typealias MsgSend =
|
|
150
|
+
@convention(c) (
|
|
151
|
+
AnyObject, Selector, AnyObject, AnyObject, AnyObject, AnyObject, AnyObject
|
|
152
|
+
) -> Void
|
|
153
|
+
let msgSend = unsafeBitCast(msgSendPointer, to: MsgSend.self)
|
|
154
|
+
|
|
155
|
+
let uuid = NSUUID()
|
|
156
|
+
callbackUUIDs[ObjectIdentifier(descriptor)] = uuid
|
|
157
|
+
|
|
158
|
+
let frameCallback: @convention(block) () -> Void = { [weak self] in
|
|
159
|
+
self?.onFrame()
|
|
160
|
+
}
|
|
161
|
+
let surfacesCallback: @convention(block) (AnyObject?, AnyObject?) -> Void = {
|
|
162
|
+
[weak self] _, _ in
|
|
163
|
+
self?.onSurfaceChange()
|
|
164
|
+
}
|
|
165
|
+
let propertiesCallback: @convention(block) () -> Void = {}
|
|
166
|
+
retainedBlocks.append(frameCallback as AnyObject)
|
|
167
|
+
retainedBlocks.append(surfacesCallback as AnyObject)
|
|
168
|
+
retainedBlocks.append(propertiesCallback as AnyObject)
|
|
169
|
+
|
|
170
|
+
msgSend(
|
|
171
|
+
descriptor,
|
|
172
|
+
selector,
|
|
173
|
+
uuid,
|
|
174
|
+
callbackQueue as AnyObject,
|
|
175
|
+
frameCallback as AnyObject,
|
|
176
|
+
surfacesCallback as AnyObject,
|
|
177
|
+
propertiesCallback as AnyObject
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private func unregisterCallbacks() {
|
|
182
|
+
let selector = NSSelectorFromString("unregisterScreenCallbacksWithUUID:")
|
|
183
|
+
for descriptor in descriptors {
|
|
184
|
+
if let uuid = callbackUUIDs[ObjectIdentifier(descriptor)],
|
|
185
|
+
descriptor.responds(to: selector)
|
|
186
|
+
{
|
|
187
|
+
descriptor.perform(selector, with: uuid)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
callbackUUIDs.removeAll()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import ObjectiveC
|
|
3
|
+
|
|
4
|
+
enum XcodeDeveloperDirectory {
|
|
5
|
+
static let current: String = {
|
|
6
|
+
if let developerDir = ProcessInfo.processInfo.environment["DEVELOPER_DIR"],
|
|
7
|
+
!developerDir.isEmpty
|
|
8
|
+
{
|
|
9
|
+
return developerDir
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let fallback = "/Applications/Xcode.app/Contents/Developer"
|
|
13
|
+
let pipe = Pipe()
|
|
14
|
+
let process = Process()
|
|
15
|
+
process.executableURL = URL(fileURLWithPath: "/usr/bin/xcode-select")
|
|
16
|
+
process.arguments = ["-p"]
|
|
17
|
+
process.standardOutput = pipe
|
|
18
|
+
do {
|
|
19
|
+
try process.run()
|
|
20
|
+
} catch {
|
|
21
|
+
return fallback
|
|
22
|
+
}
|
|
23
|
+
process.waitUntilExit()
|
|
24
|
+
return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?
|
|
25
|
+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback
|
|
26
|
+
}()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
enum PrivateSimulatorFrameworks {
|
|
30
|
+
static func load() throws {
|
|
31
|
+
try loadResult.get()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private static let loadResult: Result<Void, Error> = {
|
|
35
|
+
let developerDir = XcodeDeveloperDirectory.current
|
|
36
|
+
let candidates = [
|
|
37
|
+
"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
|
38
|
+
"\(developerDir)/Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator",
|
|
39
|
+
"\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit",
|
|
40
|
+
"\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit",
|
|
41
|
+
]
|
|
42
|
+
var failures: [String] = []
|
|
43
|
+
for path in candidates {
|
|
44
|
+
if dlopen(path, RTLD_NOW) == nil {
|
|
45
|
+
let message = dlerror().map { String(cString: $0) } ?? "unknown error"
|
|
46
|
+
failures.append("\(path): \(message)")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
guard NSClassFromString("SimServiceContext") != nil else {
|
|
50
|
+
let tried = candidates.joined(separator: ", ")
|
|
51
|
+
let errors = failures.isEmpty ? "no dlopen errors" : failures.joined(separator: "; ")
|
|
52
|
+
return .failure(
|
|
53
|
+
RecorderError.make(
|
|
54
|
+
8,
|
|
55
|
+
"Failed to load CoreSimulator/SimulatorKit (tried: \(tried); errors: \(errors))"
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return .success(())
|
|
60
|
+
}()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
enum SimulatorDeviceLookup {
|
|
64
|
+
private static let context: NSObject? = {
|
|
65
|
+
guard let contextClass = NSClassFromString("SimServiceContext") as? NSObject.Type else {
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
let sharedSelector = NSSelectorFromString("sharedServiceContextForDeveloperDir:error:")
|
|
69
|
+
return contextClass.perform(
|
|
70
|
+
sharedSelector, with: XcodeDeveloperDirectory.current, with: nil)?
|
|
71
|
+
.takeUnretainedValue() as? NSObject
|
|
72
|
+
}()
|
|
73
|
+
|
|
74
|
+
static func find(udid: String) -> NSObject? {
|
|
75
|
+
guard let context else {
|
|
76
|
+
return nil
|
|
77
|
+
}
|
|
78
|
+
let deviceSetSelector = NSSelectorFromString("defaultDeviceSetWithError:")
|
|
79
|
+
guard
|
|
80
|
+
let deviceSet = context.perform(deviceSetSelector, with: nil)?
|
|
81
|
+
.takeUnretainedValue() as? NSObject
|
|
82
|
+
else {
|
|
83
|
+
return nil
|
|
84
|
+
}
|
|
85
|
+
guard let devices = deviceSet.value(forKey: "devices") as? [NSObject] else {
|
|
86
|
+
return nil
|
|
87
|
+
}
|
|
88
|
+
return devices.first {
|
|
89
|
+
($0.value(forKey: "UDID") as? NSUUID)?.uuidString == udid
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static func stateString(udid: String) -> String? {
|
|
94
|
+
find(udid: udid)?.value(forKey: "stateString") as? String
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public enum RecorderCodec: String, Codable, Sendable {
|
|
4
|
+
case h264
|
|
5
|
+
case hevc
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
public struct SimulatorRecordingConfiguration: Sendable {
|
|
9
|
+
public var deviceUDID: String
|
|
10
|
+
public var outputDirectory: URL
|
|
11
|
+
public var fps: Int
|
|
12
|
+
public var bitrate: Int
|
|
13
|
+
public var codec: RecorderCodec
|
|
14
|
+
public var segmentDuration: TimeInterval
|
|
15
|
+
public var maxPendingFrames: Int
|
|
16
|
+
public var overwrite: Bool
|
|
17
|
+
|
|
18
|
+
public init(
|
|
19
|
+
deviceUDID: String,
|
|
20
|
+
outputDirectory: URL,
|
|
21
|
+
fps: Int = 30,
|
|
22
|
+
bitrate: Int = 30_000_000,
|
|
23
|
+
codec: RecorderCodec = .h264,
|
|
24
|
+
segmentDuration: TimeInterval = 120,
|
|
25
|
+
maxPendingFrames: Int = 8,
|
|
26
|
+
overwrite: Bool = true
|
|
27
|
+
) {
|
|
28
|
+
self.deviceUDID = deviceUDID
|
|
29
|
+
self.outputDirectory = outputDirectory
|
|
30
|
+
self.fps = fps
|
|
31
|
+
self.bitrate = bitrate
|
|
32
|
+
self.codec = codec
|
|
33
|
+
self.segmentDuration = segmentDuration
|
|
34
|
+
self.maxPendingFrames = maxPendingFrames
|
|
35
|
+
self.overwrite = overwrite
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public enum SegmentKind: String, Codable, Sendable {
|
|
40
|
+
case initialization
|
|
41
|
+
case media
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
public struct SegmentOutput: Sendable {
|
|
45
|
+
public let kind: SegmentKind
|
|
46
|
+
public let relativePath: String
|
|
47
|
+
public let url: URL
|
|
48
|
+
public let byteCount: Int
|
|
49
|
+
public let durationSeconds: Double?
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public struct MediaSegmentRecord: Codable, Sendable {
|
|
53
|
+
public let file: String
|
|
54
|
+
public let durationSeconds: Double
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public struct FirstFrameWallClock: Codable, Sendable {
|
|
58
|
+
public let unixMs: Int64
|
|
59
|
+
public let iso8601: String
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public struct RecordingManifest: Codable, Sendable {
|
|
63
|
+
public let firstFrameWallClock: FirstFrameWallClock
|
|
64
|
+
public let hlsVersion: Int?
|
|
65
|
+
public let hlsTargetDurationSeconds: Int?
|
|
66
|
+
public let hlsMediaSequence: Int?
|
|
67
|
+
public let recording: String?
|
|
68
|
+
public let initSegment: String?
|
|
69
|
+
public let segments: [MediaSegmentRecord]
|
|
70
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
final class RecordingOutputWriter: NSObject, AVAssetWriterDelegate {
|
|
5
|
+
private let rootDirectory: URL
|
|
6
|
+
private let segmentsDirectory: URL
|
|
7
|
+
let singleRecordingURL: URL
|
|
8
|
+
private let stateQueue = DispatchQueue(label: "record-sim.segment-writer")
|
|
9
|
+
private let notificationQueue = DispatchQueue(label: "record-sim.segment-notifications")
|
|
10
|
+
private let onSegment: ((SegmentOutput) -> Void)?
|
|
11
|
+
private var initSegmentPath: String?
|
|
12
|
+
private var segments: [MediaSegmentRecord] = []
|
|
13
|
+
private var nextMediaSegmentIndex = 0
|
|
14
|
+
private var firstError: Error?
|
|
15
|
+
|
|
16
|
+
init(rootDirectory: URL, onSegment: ((SegmentOutput) -> Void)?) {
|
|
17
|
+
self.rootDirectory = rootDirectory
|
|
18
|
+
self.segmentsDirectory = rootDirectory.appendingPathComponent("segments", isDirectory: true)
|
|
19
|
+
self.singleRecordingURL = rootDirectory.appendingPathComponent("recording.mp4")
|
|
20
|
+
self.onSegment = onSegment
|
|
21
|
+
super.init()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func prepare(overwrite: Bool, segmented: Bool) throws {
|
|
25
|
+
if overwrite {
|
|
26
|
+
try? FileManager.default.removeItem(at: rootDirectory)
|
|
27
|
+
}
|
|
28
|
+
try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
|
|
29
|
+
if segmented {
|
|
30
|
+
try FileManager.default.createDirectory(at: segmentsDirectory, withIntermediateDirectories: true)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
var error: Error? {
|
|
35
|
+
stateQueue.sync { firstError }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func assetWriter(
|
|
39
|
+
_ writer: AVAssetWriter,
|
|
40
|
+
didOutputSegmentData segmentData: Data,
|
|
41
|
+
segmentType: AVAssetSegmentType,
|
|
42
|
+
segmentReport: AVAssetSegmentReport?
|
|
43
|
+
) {
|
|
44
|
+
var notification: SegmentOutput?
|
|
45
|
+
|
|
46
|
+
stateQueue.sync {
|
|
47
|
+
if firstError != nil {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
do {
|
|
51
|
+
switch segmentType {
|
|
52
|
+
case .initialization:
|
|
53
|
+
let relativePath = "init.mp4"
|
|
54
|
+
let url = rootDirectory.appendingPathComponent(relativePath)
|
|
55
|
+
try segmentData.write(to: url, options: .atomic)
|
|
56
|
+
initSegmentPath = relativePath
|
|
57
|
+
notification = SegmentOutput(
|
|
58
|
+
kind: .initialization,
|
|
59
|
+
relativePath: relativePath,
|
|
60
|
+
url: url,
|
|
61
|
+
byteCount: segmentData.count,
|
|
62
|
+
durationSeconds: nil
|
|
63
|
+
)
|
|
64
|
+
case .separable:
|
|
65
|
+
let index = nextMediaSegmentIndex
|
|
66
|
+
let relativePath = String(format: "segments/segment-%06d.m4s", index)
|
|
67
|
+
let url = rootDirectory.appendingPathComponent(relativePath)
|
|
68
|
+
try segmentData.write(to: url, options: .atomic)
|
|
69
|
+
|
|
70
|
+
let videoReport = segmentReport?.trackReports.first(where: { $0.mediaType == .video })
|
|
71
|
+
let duration = videoReport.flatMap { timeSeconds($0.duration) } ?? 0
|
|
72
|
+
let record = MediaSegmentRecord(
|
|
73
|
+
file: relativePath,
|
|
74
|
+
durationSeconds: duration
|
|
75
|
+
)
|
|
76
|
+
nextMediaSegmentIndex += 1
|
|
77
|
+
segments.append(record)
|
|
78
|
+
notification = SegmentOutput(
|
|
79
|
+
kind: .media,
|
|
80
|
+
relativePath: relativePath,
|
|
81
|
+
url: url,
|
|
82
|
+
byteCount: segmentData.count,
|
|
83
|
+
durationSeconds: duration
|
|
84
|
+
)
|
|
85
|
+
default:
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
firstError = error
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if let notification, let onSegment {
|
|
94
|
+
notificationQueue.async {
|
|
95
|
+
onSegment(notification)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@discardableResult
|
|
101
|
+
func writeManifest(
|
|
102
|
+
configuration: SimulatorRecordingConfiguration,
|
|
103
|
+
firstFrameWallClock: Date
|
|
104
|
+
) throws -> RecordingManifest {
|
|
105
|
+
try stateQueue.sync {
|
|
106
|
+
if let firstError {
|
|
107
|
+
throw firstError
|
|
108
|
+
}
|
|
109
|
+
let segmented = configuration.segmentDuration > 0
|
|
110
|
+
if segmented, initSegmentPath == nil {
|
|
111
|
+
throw RecorderError.make(50, "Missing initialization segment")
|
|
112
|
+
}
|
|
113
|
+
if segmented, segments.isEmpty {
|
|
114
|
+
throw RecorderError.make(51, "No media segments were written")
|
|
115
|
+
}
|
|
116
|
+
let targetDuration = segmented
|
|
117
|
+
? max(1, Int(ceil(segments.map(\.durationSeconds).max() ?? configuration.segmentDuration)))
|
|
118
|
+
: nil
|
|
119
|
+
let manifest = RecordingManifest(
|
|
120
|
+
firstFrameWallClock: FirstFrameWallClock(
|
|
121
|
+
unixMs: unixMs(firstFrameWallClock),
|
|
122
|
+
iso8601: iso8601(firstFrameWallClock)
|
|
123
|
+
),
|
|
124
|
+
hlsVersion: segmented ? 7 : nil,
|
|
125
|
+
hlsTargetDurationSeconds: targetDuration,
|
|
126
|
+
hlsMediaSequence: segmented ? 0 : nil,
|
|
127
|
+
recording: segmented ? nil : "recording.mp4",
|
|
128
|
+
initSegment: segmented ? initSegmentPath : nil,
|
|
129
|
+
segments: segments
|
|
130
|
+
)
|
|
131
|
+
let data = try JSONEncoder.prettySorted.encode(manifest)
|
|
132
|
+
try data.write(to: rootDirectory.appendingPathComponent("session.json"), options: .atomic)
|
|
133
|
+
return manifest
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|