agent-device 0.7.4 → 0.7.5
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/src/daemon.js +19 -19
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +381 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Environment.swift +30 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +258 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift +174 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +121 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift +263 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Snapshot.swift +359 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SystemModal.swift +220 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +124 -0
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +30 -1855
- package/ios-runner/README.md +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
extension RunnerTests {
|
|
5
|
+
// MARK: - Recording
|
|
6
|
+
|
|
7
|
+
func captureRunnerFrame() -> UIImage? {
|
|
8
|
+
var image: UIImage?
|
|
9
|
+
let capture = {
|
|
10
|
+
let screenshot = XCUIScreen.main.screenshot()
|
|
11
|
+
image = screenshot.image
|
|
12
|
+
}
|
|
13
|
+
if Thread.isMainThread {
|
|
14
|
+
capture()
|
|
15
|
+
} else {
|
|
16
|
+
DispatchQueue.main.sync(execute: capture)
|
|
17
|
+
}
|
|
18
|
+
return image
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func stopRecordingIfNeeded() {
|
|
22
|
+
guard let recorder = activeRecording else { return }
|
|
23
|
+
do {
|
|
24
|
+
try recorder.stop()
|
|
25
|
+
} catch {
|
|
26
|
+
NSLog("AGENT_DEVICE_RUNNER_RECORD_STOP_FAILED=%@", String(describing: error))
|
|
27
|
+
}
|
|
28
|
+
activeRecording = nil
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func resolveRecordingOutPath(_ requestedOutPath: String) -> String {
|
|
32
|
+
let fileName = URL(fileURLWithPath: requestedOutPath).lastPathComponent
|
|
33
|
+
let fallbackName = "agent-device-recording-\(Int(Date().timeIntervalSince1970 * 1000)).mp4"
|
|
34
|
+
let safeFileName = fileName.isEmpty ? fallbackName : fileName
|
|
35
|
+
return (NSTemporaryDirectory() as NSString).appendingPathComponent(safeFileName)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// MARK: - Target Activation
|
|
39
|
+
|
|
40
|
+
func targetNeedsActivation(_ target: XCUIApplication) -> Bool {
|
|
41
|
+
switch target.state {
|
|
42
|
+
case .unknown, .notRunning, .runningBackground, .runningBackgroundSuspended:
|
|
43
|
+
return true
|
|
44
|
+
default:
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func activateTarget(bundleId: String, reason: String) -> XCUIApplication {
|
|
50
|
+
let target = XCUIApplication(bundleIdentifier: bundleId)
|
|
51
|
+
NSLog(
|
|
52
|
+
"AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d reason=%@",
|
|
53
|
+
bundleId,
|
|
54
|
+
target.state.rawValue,
|
|
55
|
+
reason
|
|
56
|
+
)
|
|
57
|
+
// activate avoids terminating and relaunching the target app
|
|
58
|
+
target.activate()
|
|
59
|
+
currentApp = target
|
|
60
|
+
currentBundleId = bundleId
|
|
61
|
+
needsFirstInteractionDelay = true
|
|
62
|
+
return target
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func withTemporaryScrollIdleTimeoutIfSupported(
|
|
66
|
+
_ target: XCUIApplication,
|
|
67
|
+
operation: () -> Void
|
|
68
|
+
) {
|
|
69
|
+
let setter = NSSelectorFromString("setWaitForIdleTimeout:")
|
|
70
|
+
guard target.responds(to: setter) else {
|
|
71
|
+
operation()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
let previous = target.value(forKey: "waitForIdleTimeout") as? NSNumber
|
|
75
|
+
target.setValue(resolveScrollInteractionIdleTimeout(), forKey: "waitForIdleTimeout")
|
|
76
|
+
defer {
|
|
77
|
+
if let previous {
|
|
78
|
+
target.setValue(previous.doubleValue, forKey: "waitForIdleTimeout")
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
operation()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private func resolveScrollInteractionIdleTimeout() -> TimeInterval {
|
|
85
|
+
guard
|
|
86
|
+
let raw = ProcessInfo.processInfo.environment["AGENT_DEVICE_IOS_INTERACTION_IDLE_TIMEOUT"],
|
|
87
|
+
!raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
88
|
+
else {
|
|
89
|
+
return scrollInteractionIdleTimeoutDefault
|
|
90
|
+
}
|
|
91
|
+
guard let parsed = Double(raw), parsed >= 0 else {
|
|
92
|
+
return scrollInteractionIdleTimeoutDefault
|
|
93
|
+
}
|
|
94
|
+
return min(parsed, 30)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func shouldRetryCommand(_ command: Command) -> Bool {
|
|
98
|
+
if RunnerEnv.isTruthy("AGENT_DEVICE_RUNNER_DISABLE_READONLY_RETRY") {
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
return isReadOnlyCommand(command)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func shouldRetryException(_ command: Command, message: String) -> Bool {
|
|
105
|
+
guard shouldRetryCommand(command) else { return false }
|
|
106
|
+
let normalized = message.lowercased()
|
|
107
|
+
if normalized.contains("kaxerrorservernotfound") {
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
if normalized.contains("main thread execution timed out") {
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
if normalized.contains("timed out") && command.command == .snapshot {
|
|
114
|
+
return true
|
|
115
|
+
}
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: - Command Classification
|
|
120
|
+
|
|
121
|
+
func isReadOnlyCommand(_ command: Command) -> Bool {
|
|
122
|
+
switch command.command {
|
|
123
|
+
case .findText, .snapshot, .screenshot:
|
|
124
|
+
return true
|
|
125
|
+
case .alert:
|
|
126
|
+
let action = (command.action ?? "get").lowercased()
|
|
127
|
+
return action == "get"
|
|
128
|
+
default:
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func shouldRetryResponse(_ response: Response) -> Bool {
|
|
134
|
+
guard response.ok == false else { return false }
|
|
135
|
+
guard let message = response.error?.message.lowercased() else { return false }
|
|
136
|
+
return message.contains("is not available")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
func isInteractionCommand(_ command: CommandType) -> Bool {
|
|
140
|
+
switch command {
|
|
141
|
+
case .tap, .longPress, .drag, .type, .swipe, .back, .appSwitcher, .pinch:
|
|
142
|
+
return true
|
|
143
|
+
default:
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func isRunnerLifecycleCommand(_ command: CommandType) -> Bool {
|
|
149
|
+
switch command {
|
|
150
|
+
case .shutdown, .recordStop, .screenshot:
|
|
151
|
+
return true
|
|
152
|
+
default:
|
|
153
|
+
return false
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// MARK: - Interaction Stabilization
|
|
158
|
+
|
|
159
|
+
func applyInteractionStabilizationIfNeeded() {
|
|
160
|
+
if needsPostSnapshotInteractionDelay {
|
|
161
|
+
sleepFor(postSnapshotInteractionDelay)
|
|
162
|
+
needsPostSnapshotInteractionDelay = false
|
|
163
|
+
}
|
|
164
|
+
if needsFirstInteractionDelay {
|
|
165
|
+
sleepFor(firstInteractionAfterActivateDelay)
|
|
166
|
+
needsFirstInteractionDelay = false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func sleepFor(_ delay: TimeInterval) {
|
|
171
|
+
guard delay > 0 else { return }
|
|
172
|
+
usleep(useconds_t(delay * 1_000_000))
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// MARK: - Wire Models
|
|
2
|
+
|
|
3
|
+
enum CommandType: String, Codable {
|
|
4
|
+
case tap
|
|
5
|
+
case tapSeries
|
|
6
|
+
case longPress
|
|
7
|
+
case drag
|
|
8
|
+
case dragSeries
|
|
9
|
+
case type
|
|
10
|
+
case swipe
|
|
11
|
+
case findText
|
|
12
|
+
case snapshot
|
|
13
|
+
case screenshot
|
|
14
|
+
case back
|
|
15
|
+
case home
|
|
16
|
+
case appSwitcher
|
|
17
|
+
case alert
|
|
18
|
+
case pinch
|
|
19
|
+
case recordStart
|
|
20
|
+
case recordStop
|
|
21
|
+
case shutdown
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
enum SwipeDirection: String, Codable {
|
|
25
|
+
case up
|
|
26
|
+
case down
|
|
27
|
+
case left
|
|
28
|
+
case right
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
struct Command: Codable {
|
|
32
|
+
let command: CommandType
|
|
33
|
+
let appBundleId: String?
|
|
34
|
+
let text: String?
|
|
35
|
+
let clearFirst: Bool?
|
|
36
|
+
let action: String?
|
|
37
|
+
let x: Double?
|
|
38
|
+
let y: Double?
|
|
39
|
+
let count: Double?
|
|
40
|
+
let intervalMs: Double?
|
|
41
|
+
let doubleTap: Bool?
|
|
42
|
+
let pauseMs: Double?
|
|
43
|
+
let pattern: String?
|
|
44
|
+
let x2: Double?
|
|
45
|
+
let y2: Double?
|
|
46
|
+
let durationMs: Double?
|
|
47
|
+
let direction: SwipeDirection?
|
|
48
|
+
let scale: Double?
|
|
49
|
+
let outPath: String?
|
|
50
|
+
let fps: Int?
|
|
51
|
+
let interactiveOnly: Bool?
|
|
52
|
+
let compact: Bool?
|
|
53
|
+
let depth: Int?
|
|
54
|
+
let scope: String?
|
|
55
|
+
let raw: Bool?
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
struct Response: Codable {
|
|
59
|
+
let ok: Bool
|
|
60
|
+
let data: DataPayload?
|
|
61
|
+
let error: ErrorPayload?
|
|
62
|
+
|
|
63
|
+
init(ok: Bool, data: DataPayload? = nil, error: ErrorPayload? = nil) {
|
|
64
|
+
self.ok = ok
|
|
65
|
+
self.data = data
|
|
66
|
+
self.error = error
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
struct DataPayload: Codable {
|
|
71
|
+
let message: String?
|
|
72
|
+
let found: Bool?
|
|
73
|
+
let items: [String]?
|
|
74
|
+
let nodes: [SnapshotNode]?
|
|
75
|
+
let truncated: Bool?
|
|
76
|
+
|
|
77
|
+
init(
|
|
78
|
+
message: String? = nil,
|
|
79
|
+
found: Bool? = nil,
|
|
80
|
+
items: [String]? = nil,
|
|
81
|
+
nodes: [SnapshotNode]? = nil,
|
|
82
|
+
truncated: Bool? = nil
|
|
83
|
+
) {
|
|
84
|
+
self.message = message
|
|
85
|
+
self.found = found
|
|
86
|
+
self.items = items
|
|
87
|
+
self.nodes = nodes
|
|
88
|
+
self.truncated = truncated
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
struct ErrorPayload: Codable {
|
|
93
|
+
let message: String
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
struct SnapshotRect: Codable {
|
|
97
|
+
let x: Double
|
|
98
|
+
let y: Double
|
|
99
|
+
let width: Double
|
|
100
|
+
let height: Double
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
struct SnapshotNode: Codable {
|
|
104
|
+
let index: Int
|
|
105
|
+
let type: String
|
|
106
|
+
let label: String?
|
|
107
|
+
let identifier: String?
|
|
108
|
+
let value: String?
|
|
109
|
+
let rect: SnapshotRect
|
|
110
|
+
let enabled: Bool
|
|
111
|
+
let hittable: Bool
|
|
112
|
+
let depth: Int
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
struct SnapshotOptions {
|
|
116
|
+
let interactiveOnly: Bool
|
|
117
|
+
let compact: Bool
|
|
118
|
+
let depth: Int?
|
|
119
|
+
let scope: String?
|
|
120
|
+
let raw: Bool
|
|
121
|
+
}
|
package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+ScreenRecorder.swift
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
extension RunnerTests {
|
|
6
|
+
// MARK: - Screen Recorder
|
|
7
|
+
|
|
8
|
+
final class ScreenRecorder {
|
|
9
|
+
private let outputPath: String
|
|
10
|
+
private let fps: Int32?
|
|
11
|
+
private let uncappedFrameInterval: TimeInterval = 0.001
|
|
12
|
+
private var uncappedTimestampTimescale: Int32 {
|
|
13
|
+
Int32(max(1, Int((1.0 / uncappedFrameInterval).rounded())))
|
|
14
|
+
}
|
|
15
|
+
private var frameInterval: TimeInterval {
|
|
16
|
+
guard let fps else { return uncappedFrameInterval }
|
|
17
|
+
return 1.0 / Double(fps)
|
|
18
|
+
}
|
|
19
|
+
private let queue = DispatchQueue(label: "agent-device.runner.recorder")
|
|
20
|
+
private let lock = NSLock()
|
|
21
|
+
private var assetWriter: AVAssetWriter?
|
|
22
|
+
private var writerInput: AVAssetWriterInput?
|
|
23
|
+
private var pixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor?
|
|
24
|
+
private var timer: DispatchSourceTimer?
|
|
25
|
+
private var recordingStartUptime: TimeInterval?
|
|
26
|
+
private var lastTimestampValue: Int64 = -1
|
|
27
|
+
private var isStopping = false
|
|
28
|
+
private var startedSession = false
|
|
29
|
+
private var startError: Error?
|
|
30
|
+
|
|
31
|
+
init(outputPath: String, fps: Int32?) {
|
|
32
|
+
self.outputPath = outputPath
|
|
33
|
+
self.fps = fps
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func start(captureFrame: @escaping () -> UIImage?) throws {
|
|
37
|
+
let url = URL(fileURLWithPath: outputPath)
|
|
38
|
+
let directory = url.deletingLastPathComponent()
|
|
39
|
+
try FileManager.default.createDirectory(
|
|
40
|
+
at: directory,
|
|
41
|
+
withIntermediateDirectories: true,
|
|
42
|
+
attributes: nil
|
|
43
|
+
)
|
|
44
|
+
if FileManager.default.fileExists(atPath: outputPath) {
|
|
45
|
+
try FileManager.default.removeItem(atPath: outputPath)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var dimensions: CGSize = .zero
|
|
49
|
+
var bootstrapImage: UIImage?
|
|
50
|
+
let bootstrapDeadline = Date().addingTimeInterval(2.0)
|
|
51
|
+
while Date() < bootstrapDeadline {
|
|
52
|
+
if let image = captureFrame(), let cgImage = image.cgImage {
|
|
53
|
+
bootstrapImage = image
|
|
54
|
+
dimensions = CGSize(width: cgImage.width, height: cgImage.height)
|
|
55
|
+
break
|
|
56
|
+
}
|
|
57
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
58
|
+
}
|
|
59
|
+
guard dimensions.width > 0, dimensions.height > 0 else {
|
|
60
|
+
throw NSError(
|
|
61
|
+
domain: "AgentDeviceRunner.Record",
|
|
62
|
+
code: 1,
|
|
63
|
+
userInfo: [NSLocalizedDescriptionKey: "failed to capture initial frame"]
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
|
68
|
+
let outputSettings: [String: Any] = [
|
|
69
|
+
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
70
|
+
AVVideoWidthKey: Int(dimensions.width),
|
|
71
|
+
AVVideoHeightKey: Int(dimensions.height),
|
|
72
|
+
]
|
|
73
|
+
let input = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings)
|
|
74
|
+
input.expectsMediaDataInRealTime = true
|
|
75
|
+
let attributes: [String: Any] = [
|
|
76
|
+
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB,
|
|
77
|
+
kCVPixelBufferWidthKey as String: Int(dimensions.width),
|
|
78
|
+
kCVPixelBufferHeightKey as String: Int(dimensions.height),
|
|
79
|
+
]
|
|
80
|
+
let adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
|
81
|
+
assetWriterInput: input,
|
|
82
|
+
sourcePixelBufferAttributes: attributes
|
|
83
|
+
)
|
|
84
|
+
guard writer.canAdd(input) else {
|
|
85
|
+
throw NSError(
|
|
86
|
+
domain: "AgentDeviceRunner.Record",
|
|
87
|
+
code: 2,
|
|
88
|
+
userInfo: [NSLocalizedDescriptionKey: "failed to add video input"]
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
writer.add(input)
|
|
92
|
+
guard writer.startWriting() else {
|
|
93
|
+
throw writer.error ?? NSError(
|
|
94
|
+
domain: "AgentDeviceRunner.Record",
|
|
95
|
+
code: 3,
|
|
96
|
+
userInfo: [NSLocalizedDescriptionKey: "failed to start writing"]
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lock.lock()
|
|
101
|
+
assetWriter = writer
|
|
102
|
+
writerInput = input
|
|
103
|
+
pixelBufferAdaptor = adaptor
|
|
104
|
+
recordingStartUptime = nil
|
|
105
|
+
lastTimestampValue = -1
|
|
106
|
+
isStopping = false
|
|
107
|
+
startedSession = false
|
|
108
|
+
startError = nil
|
|
109
|
+
lock.unlock()
|
|
110
|
+
|
|
111
|
+
if let firstImage = bootstrapImage {
|
|
112
|
+
append(image: firstImage)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
116
|
+
timer.schedule(deadline: .now() + frameInterval, repeating: frameInterval)
|
|
117
|
+
timer.setEventHandler { [weak self] in
|
|
118
|
+
guard let self else { return }
|
|
119
|
+
if self.shouldStop() { return }
|
|
120
|
+
guard let image = captureFrame() else { return }
|
|
121
|
+
self.append(image: image)
|
|
122
|
+
}
|
|
123
|
+
self.timer = timer
|
|
124
|
+
timer.resume()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func stop() throws {
|
|
128
|
+
var writer: AVAssetWriter?
|
|
129
|
+
var input: AVAssetWriterInput?
|
|
130
|
+
var appendError: Error?
|
|
131
|
+
lock.lock()
|
|
132
|
+
if isStopping {
|
|
133
|
+
lock.unlock()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
isStopping = true
|
|
137
|
+
let activeTimer = timer
|
|
138
|
+
timer = nil
|
|
139
|
+
writer = assetWriter
|
|
140
|
+
input = writerInput
|
|
141
|
+
appendError = startError
|
|
142
|
+
lock.unlock()
|
|
143
|
+
|
|
144
|
+
activeTimer?.cancel()
|
|
145
|
+
input?.markAsFinished()
|
|
146
|
+
guard let writer else { return }
|
|
147
|
+
|
|
148
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
149
|
+
writer.finishWriting {
|
|
150
|
+
semaphore.signal()
|
|
151
|
+
}
|
|
152
|
+
var stopFailure: Error?
|
|
153
|
+
let waitResult = semaphore.wait(timeout: .now() + 10)
|
|
154
|
+
if waitResult == .timedOut {
|
|
155
|
+
writer.cancelWriting()
|
|
156
|
+
stopFailure = NSError(
|
|
157
|
+
domain: "AgentDeviceRunner.Record",
|
|
158
|
+
code: 6,
|
|
159
|
+
userInfo: [NSLocalizedDescriptionKey: "recording finalization timed out"]
|
|
160
|
+
)
|
|
161
|
+
} else if let appendError {
|
|
162
|
+
stopFailure = appendError
|
|
163
|
+
} else if writer.status == .failed {
|
|
164
|
+
stopFailure = writer.error ?? NSError(
|
|
165
|
+
domain: "AgentDeviceRunner.Record",
|
|
166
|
+
code: 4,
|
|
167
|
+
userInfo: [NSLocalizedDescriptionKey: "failed to finalize recording"]
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lock.lock()
|
|
172
|
+
assetWriter = nil
|
|
173
|
+
writerInput = nil
|
|
174
|
+
pixelBufferAdaptor = nil
|
|
175
|
+
recordingStartUptime = nil
|
|
176
|
+
lastTimestampValue = -1
|
|
177
|
+
startedSession = false
|
|
178
|
+
startError = nil
|
|
179
|
+
lock.unlock()
|
|
180
|
+
|
|
181
|
+
if let stopFailure {
|
|
182
|
+
throw stopFailure
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func append(image: UIImage) {
|
|
187
|
+
guard let cgImage = image.cgImage else { return }
|
|
188
|
+
lock.lock()
|
|
189
|
+
defer { lock.unlock() }
|
|
190
|
+
if isStopping { return }
|
|
191
|
+
if startError != nil { return }
|
|
192
|
+
guard
|
|
193
|
+
let writer = assetWriter,
|
|
194
|
+
let input = writerInput,
|
|
195
|
+
let adaptor = pixelBufferAdaptor
|
|
196
|
+
else {
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if !startedSession {
|
|
200
|
+
writer.startSession(atSourceTime: .zero)
|
|
201
|
+
startedSession = true
|
|
202
|
+
}
|
|
203
|
+
guard input.isReadyForMoreMediaData else { return }
|
|
204
|
+
guard let pixelBuffer = makePixelBuffer(from: cgImage) else { return }
|
|
205
|
+
let nowUptime = ProcessInfo.processInfo.systemUptime
|
|
206
|
+
if recordingStartUptime == nil {
|
|
207
|
+
recordingStartUptime = nowUptime
|
|
208
|
+
}
|
|
209
|
+
let elapsed = max(0, nowUptime - (recordingStartUptime ?? nowUptime))
|
|
210
|
+
let timescale = fps ?? uncappedTimestampTimescale
|
|
211
|
+
var timestampValue = Int64((elapsed * Double(timescale)).rounded(.down))
|
|
212
|
+
if timestampValue <= lastTimestampValue {
|
|
213
|
+
timestampValue = lastTimestampValue + 1
|
|
214
|
+
}
|
|
215
|
+
let timestamp = CMTime(value: timestampValue, timescale: timescale)
|
|
216
|
+
if !adaptor.append(pixelBuffer, withPresentationTime: timestamp) {
|
|
217
|
+
startError = writer.error ?? NSError(
|
|
218
|
+
domain: "AgentDeviceRunner.Record",
|
|
219
|
+
code: 5,
|
|
220
|
+
userInfo: [NSLocalizedDescriptionKey: "failed to append frame"]
|
|
221
|
+
)
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
lastTimestampValue = timestampValue
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private func shouldStop() -> Bool {
|
|
228
|
+
lock.lock()
|
|
229
|
+
defer { lock.unlock() }
|
|
230
|
+
return isStopping
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private func makePixelBuffer(from image: CGImage) -> CVPixelBuffer? {
|
|
234
|
+
guard let adaptor = pixelBufferAdaptor else { return nil }
|
|
235
|
+
var pixelBuffer: CVPixelBuffer?
|
|
236
|
+
guard let pool = adaptor.pixelBufferPool else { return nil }
|
|
237
|
+
let status = CVPixelBufferPoolCreatePixelBuffer(
|
|
238
|
+
nil,
|
|
239
|
+
pool,
|
|
240
|
+
&pixelBuffer
|
|
241
|
+
)
|
|
242
|
+
guard status == kCVReturnSuccess, let pixelBuffer else { return nil }
|
|
243
|
+
|
|
244
|
+
CVPixelBufferLockBaseAddress(pixelBuffer, [])
|
|
245
|
+
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
|
|
246
|
+
guard
|
|
247
|
+
let context = CGContext(
|
|
248
|
+
data: CVPixelBufferGetBaseAddress(pixelBuffer),
|
|
249
|
+
width: image.width,
|
|
250
|
+
height: image.height,
|
|
251
|
+
bitsPerComponent: 8,
|
|
252
|
+
bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
|
|
253
|
+
space: CGColorSpaceCreateDeviceRGB(),
|
|
254
|
+
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
|
|
255
|
+
)
|
|
256
|
+
else {
|
|
257
|
+
return nil
|
|
258
|
+
}
|
|
259
|
+
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
|
|
260
|
+
return pixelBuffer
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|