@kaeawc/auto-mobile 0.0.44 → 0.0.45
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/README.md +3 -3
- package/README.md.backup +3 -3
- package/dist/ios/screen-capture/Package.swift +35 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureCore/AudioPcm16Encoder.swift +22 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureCore/CommandLineOptions.swift +152 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureCore/DeviceInfo.swift +25 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureCore/FrameProtocol.swift +113 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureCore/FrameWriter.swift +62 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureCore/SimulatorWindowInfo.swift +51 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/AudioSampleBuffer.swift +41 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/CMIOSystem.swift +26 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/DeviceCaptureSession.swift +117 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/DeviceDiscovery.swift +29 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/FrameWriter+PixelBuffer.swift +25 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/SimulatorCaptureSession.swift +159 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/SimulatorWindowDiscovery.swift +37 -0
- package/dist/ios/screen-capture/Sources/ScreenCaptureHelper/main.swift +255 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/AudioPcm16EncoderTests.swift +17 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/CommandLineOptionsTests.swift +181 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/DeviceInfoTests.swift +35 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/FrameProtocolTests.swift +123 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/FrameWriterTests.swift +62 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/SimulatorAudioCaptureAvailabilityTests.swift +27 -0
- package/dist/ios/screen-capture/Tests/ScreenCaptureCoreTests/SimulatorWindowInfoTests.swift +40 -0
- package/dist/schemas/tool-definitions.json +1274 -849
- package/dist/src/db/migrations/2026_07_03_000_repair_datetime_now_defaults.ts +11 -2
- package/dist/src/index.js +670 -407
- package/dist/src/index.js.map +1 -1
- package/package.json +25 -6
- package/schemas/tool-definitions.json +1274 -849
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import ScreenCaptureCore
|
|
4
|
+
|
|
5
|
+
/// Discovers USB-connected iOS devices visible to AVFoundation as external
|
|
6
|
+
/// muxed (audio + video) capture devices.
|
|
7
|
+
enum DeviceDiscovery {
|
|
8
|
+
static func discover() -> [AVCaptureDevice] {
|
|
9
|
+
let types: [AVCaptureDevice.DeviceType] = [.external]
|
|
10
|
+
return AVCaptureDevice.DiscoverySession(
|
|
11
|
+
deviceTypes: types,
|
|
12
|
+
mediaType: .muxed,
|
|
13
|
+
position: .unspecified
|
|
14
|
+
).devices
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static func toInfo(_ device: AVCaptureDevice) -> DeviceInfo {
|
|
18
|
+
DeviceInfo(
|
|
19
|
+
uniqueID: device.uniqueID,
|
|
20
|
+
localizedName: device.localizedName,
|
|
21
|
+
modelID: device.modelID,
|
|
22
|
+
manufacturer: device.manufacturer
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func find(uniqueID: String) -> AVCaptureDevice? {
|
|
27
|
+
discover().first { $0.uniqueID == uniqueID }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import CoreMedia
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import Foundation
|
|
4
|
+
import ScreenCaptureCore
|
|
5
|
+
|
|
6
|
+
extension FrameWriter {
|
|
7
|
+
/// Writes one frame from a `CMSampleBuffer`, locking the underlying pixel
|
|
8
|
+
/// buffer for the duration of the synchronous sink write. Returns `false`
|
|
9
|
+
/// when the sample buffer has no image (e.g. dropped/idle frames).
|
|
10
|
+
@discardableResult
|
|
11
|
+
func write(sampleBuffer: CMSampleBuffer) -> Bool {
|
|
12
|
+
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return false }
|
|
13
|
+
CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly)
|
|
14
|
+
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) }
|
|
15
|
+
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return false }
|
|
16
|
+
|
|
17
|
+
write(
|
|
18
|
+
width: CVPixelBufferGetWidth(pixelBuffer),
|
|
19
|
+
height: CVPixelBufferGetHeight(pixelBuffer),
|
|
20
|
+
bytesPerRow: CVPixelBufferGetBytesPerRow(pixelBuffer),
|
|
21
|
+
baseAddress: base
|
|
22
|
+
)
|
|
23
|
+
return true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import CoreMedia
|
|
2
|
+
import CoreVideo
|
|
3
|
+
import Foundation
|
|
4
|
+
import ScreenCaptureKit
|
|
5
|
+
import ScreenCaptureCore
|
|
6
|
+
|
|
7
|
+
/// Streams BGRA frames from a single iOS Simulator window via ScreenCaptureKit.
|
|
8
|
+
/// The frame rate is configurable (5–60); 5 is the default for typical MCP
|
|
9
|
+
/// automation workloads. Size changes (e.g. device rotation) trigger a stream
|
|
10
|
+
/// reconfiguration so frames don't get cropped.
|
|
11
|
+
final class SimulatorCaptureSession: NSObject, SCStreamOutput, SCStreamDelegate {
|
|
12
|
+
private let writer: FrameWriter
|
|
13
|
+
private let onFatalError: (Error) -> Void
|
|
14
|
+
private let queue = DispatchQueue(label: "automobile.simulator-capture.frames")
|
|
15
|
+
private var stream: SCStream?
|
|
16
|
+
private var configuredPixelWidth: Int = 0
|
|
17
|
+
private var configuredPixelHeight: Int = 0
|
|
18
|
+
private var hasReceivedFrame = false
|
|
19
|
+
private var fps: Int = CommandLineOptions.defaultSimulatorFPS
|
|
20
|
+
private var audioEnabled = false
|
|
21
|
+
|
|
22
|
+
init(writer: FrameWriter, onFatalError: @escaping (Error) -> Void) {
|
|
23
|
+
self.writer = writer
|
|
24
|
+
self.onFatalError = onFatalError
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func start(window: SCWindow, fps: Int, audio: Bool) async throws {
|
|
28
|
+
self.fps = fps
|
|
29
|
+
audioEnabled = audio
|
|
30
|
+
let filter = SCContentFilter(desktopIndependentWindow: window)
|
|
31
|
+
let config = SimulatorCaptureSession.makeConfiguration(window: window, fps: fps, audio: audio)
|
|
32
|
+
configuredPixelWidth = config.width
|
|
33
|
+
configuredPixelHeight = config.height
|
|
34
|
+
|
|
35
|
+
let stream = SCStream(filter: filter, configuration: config, delegate: self)
|
|
36
|
+
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
|
|
37
|
+
if audio {
|
|
38
|
+
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
|
|
39
|
+
}
|
|
40
|
+
try await stream.startCapture()
|
|
41
|
+
self.stream = stream
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func stop() async {
|
|
45
|
+
guard let stream = stream else { return }
|
|
46
|
+
// removeStreamOutput breaks the SCStream → self retain cycle so the
|
|
47
|
+
// session is collectable even before SCStream itself goes away.
|
|
48
|
+
try? stream.removeStreamOutput(self, type: .screen)
|
|
49
|
+
try? await stream.stopCapture()
|
|
50
|
+
self.stream = nil
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Whether at least one frame has been delivered since `start()`.
|
|
54
|
+
/// Used by the CLI to detect a silent screen-recording permission denial.
|
|
55
|
+
var hasReceivedAnyFrame: Bool { hasReceivedFrame }
|
|
56
|
+
|
|
57
|
+
// MARK: - SCStreamOutput
|
|
58
|
+
|
|
59
|
+
func stream(
|
|
60
|
+
_ stream: SCStream,
|
|
61
|
+
didOutputSampleBuffer sampleBuffer: CMSampleBuffer,
|
|
62
|
+
of type: SCStreamOutputType
|
|
63
|
+
) {
|
|
64
|
+
guard sampleBuffer.isValid else { return }
|
|
65
|
+
if type == .audio {
|
|
66
|
+
if let pcm16le = pcm16leAudio(sampleBuffer: sampleBuffer) {
|
|
67
|
+
writer.writeAudio(pcm16le: pcm16le)
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
guard type == .screen else { return }
|
|
72
|
+
|
|
73
|
+
// Drop non-complete statuses (idle, blank, suspended, stopped) so we
|
|
74
|
+
// don't re-emit identical pixels or partial buffers.
|
|
75
|
+
if let status = SimulatorCaptureSession.frameStatus(of: sampleBuffer),
|
|
76
|
+
status != .complete {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
|
81
|
+
let actualWidth = CVPixelBufferGetWidth(pixelBuffer)
|
|
82
|
+
let actualHeight = CVPixelBufferGetHeight(pixelBuffer)
|
|
83
|
+
if actualWidth != configuredPixelWidth || actualHeight != configuredPixelHeight {
|
|
84
|
+
reconfigure(width: actualWidth, height: actualHeight)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if writer.write(sampleBuffer: sampleBuffer) {
|
|
88
|
+
hasReceivedFrame = true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// MARK: - SCStreamDelegate
|
|
93
|
+
|
|
94
|
+
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
|
95
|
+
onFatalError(error)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// MARK: - Internals
|
|
99
|
+
|
|
100
|
+
private func reconfigure(width: Int, height: Int) {
|
|
101
|
+
guard let stream = stream else { return }
|
|
102
|
+
configuredPixelWidth = width
|
|
103
|
+
configuredPixelHeight = height
|
|
104
|
+
|
|
105
|
+
let updated = SCStreamConfiguration()
|
|
106
|
+
updated.width = width
|
|
107
|
+
updated.height = height
|
|
108
|
+
updated.pixelFormat = kCVPixelFormatType_32BGRA
|
|
109
|
+
updated.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
|
|
110
|
+
updated.showsCursor = false
|
|
111
|
+
updated.scalesToFit = false
|
|
112
|
+
updated.capturesAudio = audioEnabled
|
|
113
|
+
updated.sampleRate = 8_000
|
|
114
|
+
updated.channelCount = 1
|
|
115
|
+
|
|
116
|
+
// Fire-and-forget: if the update fails we keep using the old config and
|
|
117
|
+
// either resync on the next size change or stop with a delegate error.
|
|
118
|
+
Task {
|
|
119
|
+
do {
|
|
120
|
+
try await stream.updateConfiguration(updated)
|
|
121
|
+
} catch {
|
|
122
|
+
FileHandle.standardError.write(
|
|
123
|
+
Data("warn: failed to update stream configuration: \(error)\n".utf8)
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private static func makeConfiguration(window: SCWindow, fps: Int, audio: Bool) -> SCStreamConfiguration {
|
|
130
|
+
let config = SCStreamConfiguration()
|
|
131
|
+
// Logical (points) size; the delivered CVPixelBuffer is in native
|
|
132
|
+
// pixels (2x/3x for Retina), and downstream consumers must use
|
|
133
|
+
// CVPixelBufferGetWidth/Height — not these values — to allocate sinks.
|
|
134
|
+
config.width = Int(window.frame.width)
|
|
135
|
+
config.height = Int(window.frame.height)
|
|
136
|
+
config.pixelFormat = kCVPixelFormatType_32BGRA
|
|
137
|
+
config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
|
|
138
|
+
config.showsCursor = false
|
|
139
|
+
config.scalesToFit = false
|
|
140
|
+
config.capturesAudio = audio
|
|
141
|
+
config.sampleRate = 8_000
|
|
142
|
+
config.channelCount = 1
|
|
143
|
+
return config
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private static func frameStatus(of sampleBuffer: CMSampleBuffer) -> SCFrameStatus? {
|
|
147
|
+
guard
|
|
148
|
+
let attachments = CMSampleBufferGetSampleAttachmentsArray(
|
|
149
|
+
sampleBuffer, createIfNecessary: false
|
|
150
|
+
) as? [[SCStreamFrameInfo: Any]],
|
|
151
|
+
let info = attachments.first,
|
|
152
|
+
let rawStatus = info[.status] as? Int,
|
|
153
|
+
let status = SCFrameStatus(rawValue: rawStatus)
|
|
154
|
+
else {
|
|
155
|
+
return nil
|
|
156
|
+
}
|
|
157
|
+
return status
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import ScreenCaptureKit
|
|
3
|
+
import ScreenCaptureCore
|
|
4
|
+
|
|
5
|
+
enum SimulatorWindowDiscovery {
|
|
6
|
+
/// Discovers visible iOS Simulator windows via ScreenCaptureKit.
|
|
7
|
+
/// Requires screen-recording permission; throws if the user has not granted it.
|
|
8
|
+
static func discover() async throws -> [SimulatorWindowInfo] {
|
|
9
|
+
let content = try await SCShareableContent.excludingDesktopWindows(
|
|
10
|
+
false,
|
|
11
|
+
onScreenWindowsOnly: true
|
|
12
|
+
)
|
|
13
|
+
return content.windows
|
|
14
|
+
.filter { $0.owningApplication?.bundleIdentifier == simulatorBundleIdentifier }
|
|
15
|
+
.map(toInfo)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
static func find(windowID: UInt32) async throws -> SCWindow? {
|
|
19
|
+
let content = try await SCShareableContent.excludingDesktopWindows(
|
|
20
|
+
false,
|
|
21
|
+
onScreenWindowsOnly: true
|
|
22
|
+
)
|
|
23
|
+
return content.windows.first { $0.windowID == windowID }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func toInfo(_ window: SCWindow) -> SimulatorWindowInfo {
|
|
27
|
+
SimulatorWindowInfo(
|
|
28
|
+
windowID: window.windowID,
|
|
29
|
+
title: window.title,
|
|
30
|
+
applicationName: window.owningApplication?.applicationName ?? "",
|
|
31
|
+
bundleIdentifier: window.owningApplication?.bundleIdentifier ?? "",
|
|
32
|
+
processID: window.owningApplication?.processID ?? 0,
|
|
33
|
+
width: Int(window.frame.width),
|
|
34
|
+
height: Int(window.frame.height)
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import Foundation
|
|
4
|
+
import ScreenCaptureKit
|
|
5
|
+
import ScreenCaptureCore
|
|
6
|
+
|
|
7
|
+
// MARK: - Constants
|
|
8
|
+
|
|
9
|
+
// ScreenCaptureKit cold-starts just beyond two seconds on hosted macOS runners.
|
|
10
|
+
// Keep the permission hint behind the source's first-frame timeout so startup
|
|
11
|
+
// latency is not misreported as a missing Screen Recording entitlement.
|
|
12
|
+
let simulatorPermissionTimeoutSeconds: TimeInterval = 10.0
|
|
13
|
+
|
|
14
|
+
// A command-line process has no AppKit application by default. ScreenCaptureKit
|
|
15
|
+
// reaches CoreGraphics when creating an SCStream, and CoreGraphics aborts with
|
|
16
|
+
// CGS_REQUIRE_INIT unless this connection is initialized first.
|
|
17
|
+
_ = NSApplication.shared
|
|
18
|
+
|
|
19
|
+
// MARK: - Logging
|
|
20
|
+
|
|
21
|
+
func logError(_ message: String) {
|
|
22
|
+
FileHandle.standardError.write(Data("\(message)\n".utf8))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ScreenCaptureKit failures can surface as Objective-C exceptions, which would
|
|
26
|
+
// otherwise terminate this subprocess with only SIGABRT visible to its parent.
|
|
27
|
+
// Emit the exception while stderr is still connected so the daemon artifact
|
|
28
|
+
// identifies the failing capture stage.
|
|
29
|
+
NSSetUncaughtExceptionHandler { exception in
|
|
30
|
+
logError(
|
|
31
|
+
"fatal: uncaught Objective-C exception \(exception.name.rawValue): "
|
|
32
|
+
+ (exception.reason ?? "no reason provided")
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func writeJSON<T: Encodable>(_ value: T) {
|
|
37
|
+
let encoder = JSONEncoder()
|
|
38
|
+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
39
|
+
do {
|
|
40
|
+
let data = try encoder.encode(value)
|
|
41
|
+
FileHandle.standardOutput.write(data)
|
|
42
|
+
FileHandle.standardOutput.write(Data("\n".utf8))
|
|
43
|
+
} catch {
|
|
44
|
+
logError("error: failed to encode JSON: \(error)")
|
|
45
|
+
exit(1)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Argument parsing
|
|
50
|
+
|
|
51
|
+
let options: CommandLineOptions
|
|
52
|
+
do {
|
|
53
|
+
options = try CommandLineOptions.parse(CommandLine.arguments)
|
|
54
|
+
} catch let CommandLineOptions.ParseError.missingValue(flag) {
|
|
55
|
+
logError("error: missing value for \(flag)")
|
|
56
|
+
logError(CommandLineOptions.helpText)
|
|
57
|
+
exit(2)
|
|
58
|
+
} catch let CommandLineOptions.ParseError.unknownArgument(arg) {
|
|
59
|
+
logError("error: unknown argument \(arg)")
|
|
60
|
+
logError(CommandLineOptions.helpText)
|
|
61
|
+
exit(2)
|
|
62
|
+
} catch let CommandLineOptions.ParseError.invalidValue(flag, value) {
|
|
63
|
+
logError("error: invalid value '\(value)' for \(flag)")
|
|
64
|
+
logError(CommandLineOptions.helpText)
|
|
65
|
+
exit(2)
|
|
66
|
+
} catch let CommandLineOptions.ParseError.conflictingFlags(message) {
|
|
67
|
+
logError("error: conflicting flags — \(message)")
|
|
68
|
+
logError(CommandLineOptions.helpText)
|
|
69
|
+
exit(2)
|
|
70
|
+
} catch {
|
|
71
|
+
logError("error: \(error)")
|
|
72
|
+
exit(2)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: - Dispatch
|
|
76
|
+
|
|
77
|
+
switch options.mode {
|
|
78
|
+
case .help:
|
|
79
|
+
print(CommandLineOptions.helpText)
|
|
80
|
+
exit(0)
|
|
81
|
+
|
|
82
|
+
case .listDevices:
|
|
83
|
+
CMIOSystem.enableScreenCaptureDevices()
|
|
84
|
+
// Allow a brief moment for the system to register USB devices.
|
|
85
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
86
|
+
let infos = DeviceDiscovery.discover().map(DeviceDiscovery.toInfo)
|
|
87
|
+
writeJSON(DeviceListResponse(devices: infos))
|
|
88
|
+
exit(0)
|
|
89
|
+
|
|
90
|
+
case .listSimulators:
|
|
91
|
+
switch runBlocking({ try await SimulatorWindowDiscovery.discover() }) {
|
|
92
|
+
case .success(let windows):
|
|
93
|
+
writeJSON(SimulatorWindowListResponse(windows: windows))
|
|
94
|
+
exit(0)
|
|
95
|
+
case .failure(let error):
|
|
96
|
+
logError("error: failed to query simulator windows: \(error)")
|
|
97
|
+
logError("hint: grant Screen Recording permission to your terminal/IDE.")
|
|
98
|
+
exit(1)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case .captureSimulator(let windowID, let fps, let audio):
|
|
102
|
+
if audio {
|
|
103
|
+
switch runBlocking({ try await SimulatorWindowDiscovery.discover() }) {
|
|
104
|
+
case .success(let windows):
|
|
105
|
+
if let error = SimulatorAudioCaptureAvailability.errorMessage(for: windows) {
|
|
106
|
+
logError("error: \(error)")
|
|
107
|
+
exit(1)
|
|
108
|
+
}
|
|
109
|
+
case .failure(let error):
|
|
110
|
+
logError("error: failed to query simulator windows: \(error)")
|
|
111
|
+
exit(1)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let window: SCWindow
|
|
116
|
+
switch runBlocking({ try await SimulatorWindowDiscovery.find(windowID: windowID) }) {
|
|
117
|
+
case .success(.some(let resolved)):
|
|
118
|
+
window = resolved
|
|
119
|
+
case .success(.none):
|
|
120
|
+
logError("error: no window with CGWindowID \(windowID)")
|
|
121
|
+
exit(1)
|
|
122
|
+
case .failure(let error):
|
|
123
|
+
logError("error: failed to query simulator windows: \(error)")
|
|
124
|
+
exit(1)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let sink = FileHandleFrameSink(handle: .standardOutput)
|
|
128
|
+
let writer = FrameWriter(sink: sink)
|
|
129
|
+
let simSession = SimulatorCaptureSession(writer: writer) { error in
|
|
130
|
+
logError("error: ScreenCaptureKit stream stopped: \(error)")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if case .failure(let error) = runBlocking({
|
|
134
|
+
try await simSession.start(window: window, fps: fps, audio: audio)
|
|
135
|
+
}) {
|
|
136
|
+
logError("error: failed to start simulator capture: \(error)")
|
|
137
|
+
exit(1)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ScreenCaptureKit silently emits no frames when the host process lacks
|
|
141
|
+
// Screen Recording permission — there's no error to surface. Emit a hint
|
|
142
|
+
// on stderr if nothing arrives within the deadline; this leaves stdout
|
|
143
|
+
// (the frame channel) untouched.
|
|
144
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + simulatorPermissionTimeoutSeconds) {
|
|
145
|
+
if !simSession.hasReceivedAnyFrame {
|
|
146
|
+
logError(
|
|
147
|
+
"warn: no frames received within \(simulatorPermissionTimeoutSeconds)s. "
|
|
148
|
+
+ "Grant 'Screen Recording' to your terminal/IDE in "
|
|
149
|
+
+ "System Settings → Privacy & Security → Screen Recording."
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
installShutdownHandlers {
|
|
155
|
+
runBlocking { await simSession.stop() }
|
|
156
|
+
exit(0)
|
|
157
|
+
}
|
|
158
|
+
RunLoop.main.run()
|
|
159
|
+
|
|
160
|
+
case .capture(let deviceID):
|
|
161
|
+
CMIOSystem.enableScreenCaptureDevices()
|
|
162
|
+
Thread.sleep(forTimeInterval: 0.5)
|
|
163
|
+
|
|
164
|
+
let resolved: AVCaptureDevice?
|
|
165
|
+
if let id = deviceID {
|
|
166
|
+
resolved = DeviceDiscovery.find(uniqueID: id)
|
|
167
|
+
if resolved == nil {
|
|
168
|
+
logError("error: no device with uniqueID '\(id)'")
|
|
169
|
+
exit(1)
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
resolved = DeviceDiscovery.discover().first
|
|
173
|
+
if resolved == nil {
|
|
174
|
+
logError("error: no muxed external capture devices found")
|
|
175
|
+
exit(1)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
guard let device = resolved else { exit(1) }
|
|
179
|
+
|
|
180
|
+
let sink = FileHandleFrameSink(handle: .standardOutput)
|
|
181
|
+
let writer = FrameWriter(sink: sink)
|
|
182
|
+
let captureSession = DeviceCaptureSession(writer: writer) { error in
|
|
183
|
+
logError("error: iOS device capture failed: \(error)")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
do {
|
|
187
|
+
try captureSession.start(device: device)
|
|
188
|
+
} catch {
|
|
189
|
+
logError("error: failed to start capture session: \(error)")
|
|
190
|
+
exit(1)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
installShutdownHandlers {
|
|
194
|
+
captureSession.stop()
|
|
195
|
+
exit(0)
|
|
196
|
+
}
|
|
197
|
+
RunLoop.main.run()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// MARK: - Async/sync bridges
|
|
201
|
+
|
|
202
|
+
// A reference box so the Task closure mutates a captured class instead of a
|
|
203
|
+
// `var`. Swift 5.9 (Xcode 15) rejects `var` capture in `Task { … }` even when
|
|
204
|
+
// the semaphore enforces a happens-before relationship; class capture is fine.
|
|
205
|
+
private final class Box<T> {
|
|
206
|
+
var value: T
|
|
207
|
+
init(_ value: T) { self.value = value }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func runBlocking<T>(_ body: @escaping () async throws -> T) -> Result<T, Error> {
|
|
211
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
212
|
+
let box = Box<Result<T, Error>>(.failure(CancellationError()))
|
|
213
|
+
Task {
|
|
214
|
+
do {
|
|
215
|
+
box.value = .success(try await body())
|
|
216
|
+
} catch {
|
|
217
|
+
box.value = .failure(error)
|
|
218
|
+
}
|
|
219
|
+
semaphore.signal()
|
|
220
|
+
}
|
|
221
|
+
semaphore.wait()
|
|
222
|
+
return box.value
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func runBlocking<T>(_ body: @escaping () async -> T) -> T {
|
|
226
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
227
|
+
let box = Box<T?>(nil)
|
|
228
|
+
Task {
|
|
229
|
+
box.value = await body()
|
|
230
|
+
semaphore.signal()
|
|
231
|
+
}
|
|
232
|
+
semaphore.wait()
|
|
233
|
+
// box.value is set by the async body before semaphore.signal(); wait() blocks until then.
|
|
234
|
+
return box.value! // swiftlint:disable:this force_unwrapping
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: - Signal handling
|
|
238
|
+
|
|
239
|
+
// Retain signal sources beyond `installShutdownHandlers` to keep them alive
|
|
240
|
+
// for the duration of the run loop.
|
|
241
|
+
private var retainedSignalSources: [DispatchSourceSignal] = []
|
|
242
|
+
|
|
243
|
+
func installShutdownHandlers(_ handler: @escaping () -> Void) {
|
|
244
|
+
let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
|
|
245
|
+
let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
|
|
246
|
+
// Ignore the default disposition so the DispatchSources are the only
|
|
247
|
+
// handlers that run.
|
|
248
|
+
signal(SIGTERM, SIG_IGN)
|
|
249
|
+
signal(SIGINT, SIG_IGN)
|
|
250
|
+
termSource.setEventHandler(handler: handler)
|
|
251
|
+
intSource.setEventHandler(handler: handler)
|
|
252
|
+
termSource.resume()
|
|
253
|
+
intSource.resume()
|
|
254
|
+
retainedSignalSources = [termSource, intSource]
|
|
255
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import ScreenCaptureCore
|
|
3
|
+
|
|
4
|
+
final class AudioPcm16EncoderTests: XCTestCase {
|
|
5
|
+
func testEncodesFloat32LittleEndianAsPCM16LE() {
|
|
6
|
+
let values: [Float] = [-1, 0, 1]
|
|
7
|
+
let input = values.withUnsafeBytes { Data($0) }
|
|
8
|
+
|
|
9
|
+
let output = AudioPcm16Encoder.encodeFloat32LE(input)
|
|
10
|
+
|
|
11
|
+
XCTAssertEqual(output, Data([0x01, 0x80, 0x00, 0x00, 0xFF, 0x7F]))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func testRejectsTruncatedFloat32Input() {
|
|
15
|
+
XCTAssertNil(AudioPcm16Encoder.encodeFloat32LE(Data([0, 0, 0])))
|
|
16
|
+
}
|
|
17
|
+
}
|