426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
759 lines
26 KiB
Swift
759 lines
26 KiB
Swift
import Foundation
|
|
import Capacitor
|
|
import ReplayKit
|
|
import AVFoundation
|
|
import ImageIO
|
|
|
|
@objc(ScreenCapturePlugin)
|
|
public class ScreenCapturePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
public let identifier = "ScreenCapturePlugin"
|
|
public let jsName = "ScreenCapture"
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
CAPPluginMethod(name: "isSupported", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "captureScreenshot", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "startRecording", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "stopRecording", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "pauseRecording", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "resumeRecording", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "getRecordingState", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
|
|
]
|
|
|
|
// MARK: - Thread-safe capture state
|
|
|
|
/// Serializes access to the AVAssetWriter and its inputs from the ReplayKit
|
|
/// capture handler, which may fire on arbitrary background queues.
|
|
private final class CaptureState: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
|
|
var writer: AVAssetWriter?
|
|
var videoInput: AVAssetWriterInput?
|
|
var audioInput: AVAssetWriterInput?
|
|
var sessionStarted = false
|
|
var sawVideo = false
|
|
var lastVideoTime: CMTime?
|
|
var handlerError: Error?
|
|
var isPaused = false
|
|
var outputURL: URL?
|
|
|
|
// Config captured at recording start
|
|
var targetFps: Double = 30
|
|
var videoBitrate: Int = 6_000_000
|
|
var includeSystemAudio = true
|
|
var includeMicrophone = false
|
|
|
|
func withLock<T>(_ body: (CaptureState) -> T) -> T {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return body(self)
|
|
}
|
|
}
|
|
|
|
private let recorder = RPScreenRecorder.shared()
|
|
private var captureState: CaptureState?
|
|
private var recordingStartTime: Date?
|
|
private var pausedDuration: TimeInterval = 0
|
|
private var lastPauseStart: Date?
|
|
private var recordingTimer: Timer?
|
|
private var maxDurationTimer: Timer?
|
|
private var pendingStopCall: CAPPluginCall?
|
|
private let captureQueue = DispatchQueue(label: "screencapture.record", qos: .userInitiated)
|
|
|
|
// MARK: - isSupported
|
|
|
|
@objc func isSupported(_ call: CAPPluginCall) {
|
|
var features: [String] = ["screenshot"] // always available via UIKit
|
|
|
|
if recorder.isAvailable {
|
|
features.append("recording")
|
|
features.append("systemAudio")
|
|
features.append("microphone")
|
|
}
|
|
|
|
call.resolve([
|
|
"supported": recorder.isAvailable,
|
|
"features": features,
|
|
])
|
|
}
|
|
|
|
// MARK: - captureScreenshot
|
|
|
|
@objc func captureScreenshot(_ call: CAPPluginCall) {
|
|
let format = call.getString("format") ?? "png"
|
|
let quality = call.getFloat("quality") ?? 100
|
|
let scale = call.getFloat("scale") ?? 1
|
|
let captureSystemUI = call.getBool("captureSystemUI") ?? false
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
let windows = self.gatherWindows(captureSystemUI: captureSystemUI)
|
|
guard let primaryWindow = windows.first else {
|
|
call.reject("No window available")
|
|
return
|
|
}
|
|
|
|
let bounds = primaryWindow.bounds
|
|
|
|
// UIGraphicsImageRenderer: modern replacement for UIGraphicsBeginImageContextWithOptions
|
|
let rendererFormat = UIGraphicsImageRendererFormat()
|
|
rendererFormat.scale = CGFloat(scale)
|
|
rendererFormat.opaque = true
|
|
|
|
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: rendererFormat)
|
|
let image = renderer.image { ctx in
|
|
for window in windows {
|
|
window.layer.render(in: ctx.cgContext)
|
|
}
|
|
}
|
|
|
|
// Encode to the requested format
|
|
var data: Data?
|
|
var outputFormat = format
|
|
|
|
switch format {
|
|
case "jpeg":
|
|
data = image.jpegData(compressionQuality: CGFloat(quality / 100))
|
|
case "webp":
|
|
// Attempt WebP encoding via ImageIO (iOS 14+)
|
|
data = self.encodeWebP(image: image, quality: CGFloat(quality / 100))
|
|
if data == nil {
|
|
data = image.pngData()
|
|
outputFormat = "png"
|
|
}
|
|
default:
|
|
data = image.pngData()
|
|
outputFormat = "png"
|
|
}
|
|
|
|
guard let imageData = data else {
|
|
call.reject("Failed to encode image")
|
|
return
|
|
}
|
|
|
|
let outputWidth = Int(bounds.width * CGFloat(scale))
|
|
let outputHeight = Int(bounds.height * CGFloat(scale))
|
|
|
|
call.resolve([
|
|
"base64": imageData.base64EncodedString(),
|
|
"format": outputFormat,
|
|
"width": outputWidth,
|
|
"height": outputHeight,
|
|
"timestamp": Date().timeIntervalSince1970 * 1000,
|
|
])
|
|
}
|
|
}
|
|
|
|
// MARK: - startRecording
|
|
|
|
@objc func startRecording(_ call: CAPPluginCall) {
|
|
guard recorder.isAvailable else {
|
|
call.reject("Screen recording not available")
|
|
return
|
|
}
|
|
|
|
if captureState != nil {
|
|
call.reject("Recording already in progress")
|
|
return
|
|
}
|
|
|
|
// Parse options matching the TS ScreenRecordingOptions interface
|
|
let qualityPreset = call.getString("quality") ?? "high"
|
|
let maxDuration = call.getDouble("maxDuration") // seconds
|
|
let fps = call.getDouble("fps")
|
|
let bitrate = call.getInt("bitrate")
|
|
let captureAudio = call.getBool("captureAudio") ?? true
|
|
let captureSystemAudio = call.getBool("captureSystemAudio") ?? captureAudio
|
|
let captureMicrophone = call.getBool("captureMicrophone") ?? false
|
|
|
|
// Resolve bitrate: explicit value takes precedence over quality preset
|
|
let resolvedBitrate = bitrate ?? Self.bitrateForQuality(qualityPreset)
|
|
let resolvedFps = Self.clampFps(fps ?? 30)
|
|
|
|
recorder.isMicrophoneEnabled = captureMicrophone
|
|
|
|
// Prepare temp output file
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
let fileName = "screen_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(8)).mp4"
|
|
let outputURL = tempDir.appendingPathComponent(fileName)
|
|
try? FileManager.default.removeItem(at: outputURL)
|
|
|
|
// Thread-safe state with deferred writer init (ported from classic ScreenRecordService)
|
|
let state = CaptureState()
|
|
state.outputURL = outputURL
|
|
state.targetFps = resolvedFps
|
|
state.videoBitrate = resolvedBitrate
|
|
state.includeSystemAudio = captureSystemAudio
|
|
state.includeMicrophone = captureMicrophone
|
|
self.captureState = state
|
|
|
|
recorder.startCapture(handler: { [weak self] sampleBuffer, sampleType, error in
|
|
guard let self = self else { return }
|
|
// Serialize writes on a dedicated queue (classic ScreenRecordService pattern)
|
|
self.captureQueue.async {
|
|
self.handleSample(sampleBuffer, type: sampleType, error: error, state: state)
|
|
}
|
|
}) { [weak self] error in
|
|
guard let self = self else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
self.captureState = nil
|
|
call.reject("Failed to start recording: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
self.recordingStartTime = Date()
|
|
self.pausedDuration = 0
|
|
self.lastPauseStart = nil
|
|
self.startRecordingTimer()
|
|
|
|
// Auto-stop after maxDuration (safety limit)
|
|
if let maxDuration = maxDuration, maxDuration > 0 {
|
|
self.maxDurationTimer = Timer.scheduledTimer(
|
|
withTimeInterval: maxDuration,
|
|
repeats: false
|
|
) { [weak self] _ in
|
|
self?.autoStopRecording()
|
|
}
|
|
}
|
|
|
|
self.notifyListeners("recordingState", data: [
|
|
"isRecording": true,
|
|
"duration": 0,
|
|
"fileSize": 0,
|
|
"fps": resolvedFps,
|
|
])
|
|
|
|
call.resolve()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - stopRecording
|
|
|
|
@objc func stopRecording(_ call: CAPPluginCall) {
|
|
guard let state = captureState else {
|
|
call.reject("Not recording")
|
|
return
|
|
}
|
|
|
|
pendingStopCall = call
|
|
recordingTimer?.invalidate()
|
|
recordingTimer = nil
|
|
maxDurationTimer?.invalidate()
|
|
maxDurationTimer = nil
|
|
|
|
recorder.stopCapture { [weak self] error in
|
|
guard let self = self else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
if let error = error {
|
|
self.cleanup()
|
|
call.reject("Failed to stop recording: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
self.finishRecording(state: state)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - pauseRecording
|
|
|
|
@objc func pauseRecording(_ call: CAPPluginCall) {
|
|
guard let state = captureState else {
|
|
call.reject("Not recording")
|
|
return
|
|
}
|
|
|
|
let alreadyPaused = state.withLock { $0.isPaused }
|
|
if alreadyPaused {
|
|
call.reject("Already paused")
|
|
return
|
|
}
|
|
|
|
state.withLock { $0.isPaused = true }
|
|
lastPauseStart = Date()
|
|
|
|
notifyListeners("recordingState", data: [
|
|
"isRecording": true,
|
|
"duration": currentDuration(),
|
|
"fileSize": currentFileSize(),
|
|
])
|
|
|
|
call.resolve()
|
|
}
|
|
|
|
// MARK: - resumeRecording
|
|
|
|
@objc func resumeRecording(_ call: CAPPluginCall) {
|
|
guard let state = captureState else {
|
|
call.reject("Not recording")
|
|
return
|
|
}
|
|
|
|
let wasPaused = state.withLock { $0.isPaused }
|
|
if !wasPaused {
|
|
call.reject("Not paused")
|
|
return
|
|
}
|
|
|
|
// Accumulate elapsed pause time
|
|
if let pauseStart = lastPauseStart {
|
|
pausedDuration += Date().timeIntervalSince(pauseStart)
|
|
}
|
|
lastPauseStart = nil
|
|
|
|
state.withLock { $0.isPaused = false }
|
|
|
|
notifyListeners("recordingState", data: [
|
|
"isRecording": true,
|
|
"duration": currentDuration(),
|
|
"fileSize": currentFileSize(),
|
|
])
|
|
|
|
call.resolve()
|
|
}
|
|
|
|
// MARK: - getRecordingState
|
|
|
|
@objc func getRecordingState(_ call: CAPPluginCall) {
|
|
let isRecording = captureState != nil
|
|
let isPaused = captureState?.withLock { $0.isPaused } ?? false
|
|
let targetFps = captureState?.withLock { $0.targetFps } ?? 0
|
|
|
|
// state string supplements the boolean for richer state reporting
|
|
let state: String
|
|
if !isRecording { state = "idle" }
|
|
else if isPaused { state = "paused" }
|
|
else { state = "recording" }
|
|
|
|
call.resolve([
|
|
"isRecording": isRecording,
|
|
"state": state,
|
|
"duration": currentDuration(),
|
|
"fileSize": currentFileSize(),
|
|
"fps": targetFps,
|
|
])
|
|
}
|
|
|
|
// MARK: - Permissions
|
|
|
|
@objc public override func checkPermissions(_ call: CAPPluginCall) {
|
|
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
|
|
call.resolve([
|
|
"screenCapture": recorder.isAvailable ? "granted" : "not_supported",
|
|
"microphone": permissionString(from: micStatus),
|
|
])
|
|
}
|
|
|
|
@objc public override func requestPermissions(_ call: CAPPluginCall) {
|
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
|
|
call.resolve([
|
|
"screenCapture": self.recorder.isAvailable ? "granted" : "not_supported",
|
|
"microphone": self.permissionString(from: micStatus),
|
|
])
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Sample handling (ported from classic ScreenRecordService)
|
|
|
|
private func handleSample(
|
|
_ sample: CMSampleBuffer,
|
|
type: RPSampleBufferType,
|
|
error: Error?,
|
|
state: CaptureState
|
|
) {
|
|
if let error = error {
|
|
state.withLock { s in
|
|
if s.handlerError == nil { s.handlerError = error }
|
|
}
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.notifyListeners("error", data: [
|
|
"code": "CAPTURE_ERROR",
|
|
"message": error.localizedDescription,
|
|
])
|
|
}
|
|
return
|
|
}
|
|
|
|
guard CMSampleBufferDataIsReady(sample) else { return }
|
|
|
|
// Discard samples while paused (recording resumes seamlessly on unpause)
|
|
let isPaused = state.withLock { $0.isPaused }
|
|
if isPaused { return }
|
|
|
|
switch type {
|
|
case .video:
|
|
handleVideoSample(sample, state: state)
|
|
case .audioApp:
|
|
if state.withLock({ $0.includeSystemAudio }) {
|
|
handleAudioSample(sample, state: state)
|
|
}
|
|
case .audioMic:
|
|
if state.withLock({ $0.includeMicrophone }) {
|
|
handleAudioSample(sample, state: state)
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Process a video sample with FPS throttling and deferred writer initialization.
|
|
private func handleVideoSample(_ sample: CMSampleBuffer, state: CaptureState) {
|
|
let pts = CMSampleBufferGetPresentationTimeStamp(sample)
|
|
let targetFps = state.withLock { $0.targetFps }
|
|
|
|
// FPS throttling: skip frames that arrive faster than requested (classic pattern)
|
|
let shouldSkip = state.withLock { s in
|
|
if let lastTime = s.lastVideoTime {
|
|
let delta = CMTimeSubtract(pts, lastTime)
|
|
return delta.seconds < (1.0 / targetFps)
|
|
}
|
|
return false
|
|
}
|
|
if shouldSkip { return }
|
|
|
|
// Deferred writer init on first video sample to get exact pixel dimensions (classic pattern)
|
|
let hasWriter = state.withLock { $0.writer != nil }
|
|
if !hasWriter {
|
|
prepareWriter(from: sample, state: state, pts: pts)
|
|
}
|
|
|
|
let (vInput, started) = state.withLock { ($0.videoInput, $0.sessionStarted) }
|
|
guard let vInput = vInput, started else { return }
|
|
|
|
if vInput.isReadyForMoreMediaData {
|
|
if vInput.append(sample) {
|
|
state.withLock { s in
|
|
s.sawVideo = true
|
|
s.lastVideoTime = pts
|
|
}
|
|
} else {
|
|
let err = state.withLock { $0.writer?.error }
|
|
if let err = err {
|
|
state.withLock { s in
|
|
if s.handlerError == nil { s.handlerError = err }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create the AVAssetWriter lazily from the first video sample's actual pixel dimensions.
|
|
/// This is more robust than pre-calculating from UIScreen (classic ScreenRecordService pattern).
|
|
private func prepareWriter(from sample: CMSampleBuffer, state: CaptureState, pts: CMTime) {
|
|
guard let imageBuffer = CMSampleBufferGetImageBuffer(sample) else {
|
|
state.withLock { s in
|
|
if s.handlerError == nil {
|
|
s.handlerError = NSError(domain: "ScreenCapture", code: 1, userInfo: [
|
|
NSLocalizedDescriptionKey: "Missing image buffer in video sample",
|
|
])
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
let width = CVPixelBufferGetWidth(imageBuffer)
|
|
let height = CVPixelBufferGetHeight(imageBuffer)
|
|
let bitrate = state.withLock { $0.videoBitrate }
|
|
|
|
guard let url = state.withLock({ $0.outputURL }) else { return }
|
|
|
|
do {
|
|
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
|
|
|
let videoSettings: [String: Any] = [
|
|
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
AVVideoWidthKey: width,
|
|
AVVideoHeightKey: height,
|
|
AVVideoCompressionPropertiesKey: [
|
|
AVVideoAverageBitRateKey: bitrate,
|
|
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
|
|
],
|
|
]
|
|
|
|
let vInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
|
vInput.expectsMediaDataInRealTime = true
|
|
guard writer.canAdd(vInput) else {
|
|
throw NSError(domain: "ScreenCapture", code: 2, userInfo: [
|
|
NSLocalizedDescriptionKey: "Cannot add video input to writer",
|
|
])
|
|
}
|
|
writer.add(vInput)
|
|
|
|
// Audio input for system audio and/or microphone
|
|
let needsAudio = state.withLock { $0.includeSystemAudio || $0.includeMicrophone }
|
|
if needsAudio {
|
|
let audioSettings: [String: Any] = [
|
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
AVSampleRateKey: 44100,
|
|
AVNumberOfChannelsKey: 2,
|
|
AVEncoderBitRateKey: 128000,
|
|
]
|
|
let aInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
|
aInput.expectsMediaDataInRealTime = true
|
|
if writer.canAdd(aInput) {
|
|
writer.add(aInput)
|
|
state.withLock { $0.audioInput = aInput }
|
|
}
|
|
}
|
|
|
|
guard writer.startWriting() else {
|
|
throw NSError(domain: "ScreenCapture", code: 3, userInfo: [
|
|
NSLocalizedDescriptionKey: writer.error?.localizedDescription
|
|
?? "Failed to start asset writer",
|
|
])
|
|
}
|
|
|
|
// Start session at first sample PTS, not .zero, for correct timing
|
|
writer.startSession(atSourceTime: pts)
|
|
|
|
state.withLock { s in
|
|
s.writer = writer
|
|
s.videoInput = vInput
|
|
s.sessionStarted = true
|
|
}
|
|
} catch {
|
|
state.withLock { s in
|
|
if s.handlerError == nil { s.handlerError = error }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleAudioSample(_ sample: CMSampleBuffer, state: CaptureState) {
|
|
let (aInput, started) = state.withLock { ($0.audioInput, $0.sessionStarted) }
|
|
guard let aInput = aInput, started else { return }
|
|
if aInput.isReadyForMoreMediaData {
|
|
_ = aInput.append(sample)
|
|
}
|
|
}
|
|
|
|
// MARK: - Recording lifecycle helpers
|
|
|
|
private func startRecordingTimer() {
|
|
recordingTimer?.invalidate()
|
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
|
guard let self = self, let state = self.captureState else { return }
|
|
let isPaused = state.withLock { $0.isPaused }
|
|
|
|
self.notifyListeners("recordingState", data: [
|
|
"isRecording": true,
|
|
"isPaused": isPaused,
|
|
"duration": self.currentDuration(),
|
|
"fileSize": self.currentFileSize(),
|
|
])
|
|
}
|
|
}
|
|
|
|
/// Auto-stop triggered by maxDuration timer
|
|
private func autoStopRecording() {
|
|
guard let state = captureState else { return }
|
|
|
|
recordingTimer?.invalidate()
|
|
recordingTimer = nil
|
|
maxDurationTimer?.invalidate()
|
|
maxDurationTimer = nil
|
|
|
|
recorder.stopCapture { [weak self] error in
|
|
guard let self = self else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
if error != nil {
|
|
self.notifyListeners("error", data: [
|
|
"code": "AUTO_STOP_FAILED",
|
|
"message": error!.localizedDescription,
|
|
])
|
|
self.cleanup()
|
|
return
|
|
}
|
|
self.finishRecording(state: state)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finishRecording(state: CaptureState) {
|
|
let call = pendingStopCall
|
|
pendingStopCall = nil
|
|
|
|
let duration = currentDuration()
|
|
|
|
// Check for capture handler errors
|
|
if let err = state.withLock({ $0.handlerError }) {
|
|
cleanup()
|
|
call?.reject("Recording failed: \(err.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
let vInput = state.withLock { $0.videoInput }
|
|
let aInput = state.withLock { $0.audioInput }
|
|
let writer = state.withLock { $0.writer }
|
|
let sawVideo = state.withLock { $0.sawVideo }
|
|
|
|
guard let writer = writer, sawVideo else {
|
|
cleanup()
|
|
call?.reject("No video frames were captured")
|
|
return
|
|
}
|
|
|
|
vInput?.markAsFinished()
|
|
aInput?.markAsFinished()
|
|
|
|
writer.finishWriting { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
if let writerError = writer.error {
|
|
self.cleanup()
|
|
call?.reject("Failed to finalize recording: \(writerError.localizedDescription)")
|
|
return
|
|
}
|
|
|
|
guard let url = state.withLock({ $0.outputURL }) else {
|
|
self.cleanup()
|
|
call?.reject("No output file")
|
|
return
|
|
}
|
|
|
|
var fileSize: Int64 = 0
|
|
var width = 0
|
|
var height = 0
|
|
|
|
if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) {
|
|
fileSize = attrs[.size] as? Int64 ?? 0
|
|
}
|
|
|
|
// Read actual video track dimensions from the written file
|
|
let asset = AVAsset(url: url)
|
|
if let track = asset.tracks(withMediaType: .video).first {
|
|
let size = track.naturalSize.applying(track.preferredTransform)
|
|
width = Int(abs(size.width))
|
|
height = Int(abs(size.height))
|
|
}
|
|
|
|
self.notifyListeners("recordingState", data: [
|
|
"isRecording": false,
|
|
"duration": duration,
|
|
"fileSize": fileSize,
|
|
])
|
|
|
|
call?.resolve([
|
|
"path": url.absoluteString,
|
|
"duration": duration,
|
|
"width": width,
|
|
"height": height,
|
|
"fileSize": fileSize,
|
|
"mimeType": "video/mp4",
|
|
])
|
|
|
|
self.cleanup()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cleanup() {
|
|
captureState = nil
|
|
recordingStartTime = nil
|
|
pausedDuration = 0
|
|
lastPauseStart = nil
|
|
recordingTimer?.invalidate()
|
|
recordingTimer = nil
|
|
maxDurationTimer?.invalidate()
|
|
maxDurationTimer = nil
|
|
}
|
|
|
|
// MARK: - Screenshot helpers
|
|
|
|
/// Gather UIWindows to render. When captureSystemUI is true, include all windows
|
|
/// (status bar, alerts, etc.) sorted by window level.
|
|
private func gatherWindows(captureSystemUI: Bool) -> [UIWindow] {
|
|
if captureSystemUI {
|
|
let scenes = UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
let allWindows = scenes.flatMap { $0.windows }
|
|
.sorted { $0.windowLevel.rawValue < $1.windowLevel.rawValue }
|
|
return allWindows
|
|
} else {
|
|
if let window = bridge?.webView?.window {
|
|
return [window]
|
|
}
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Encode a UIImage to WebP using ImageIO (available iOS 14+).
|
|
/// Returns nil if WebP encoding is not supported on this OS version.
|
|
private func encodeWebP(image: UIImage, quality: CGFloat) -> Data? {
|
|
guard let cgImage = image.cgImage else { return nil }
|
|
let data = NSMutableData()
|
|
guard let dest = CGImageDestinationCreateWithData(
|
|
data as CFMutableData,
|
|
"public.webp" as CFString,
|
|
1,
|
|
nil
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
let options: [CFString: Any] = [
|
|
kCGImageDestinationLossyCompressionQuality: quality,
|
|
]
|
|
CGImageDestinationAddImage(dest, cgImage, options as CFDictionary)
|
|
|
|
guard CGImageDestinationFinalize(dest) else { return nil }
|
|
return data as Data
|
|
}
|
|
|
|
// MARK: - Recording state helpers
|
|
|
|
/// Compute active recording duration, excluding time spent paused.
|
|
private func currentDuration() -> Double {
|
|
guard let start = recordingStartTime else { return 0 }
|
|
var elapsed = Date().timeIntervalSince(start)
|
|
elapsed -= pausedDuration
|
|
// Subtract ongoing pause if currently paused
|
|
if let pauseStart = lastPauseStart {
|
|
elapsed -= Date().timeIntervalSince(pauseStart)
|
|
}
|
|
return max(0, elapsed)
|
|
}
|
|
|
|
private func currentFileSize() -> Int64 {
|
|
guard let url = captureState?.withLock({ $0.outputURL }) else { return 0 }
|
|
return (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
|
|
}
|
|
|
|
/// Map quality preset string to video bitrate in bits per second.
|
|
private static func bitrateForQuality(_ quality: String) -> Int {
|
|
switch quality {
|
|
case "low": return 1_000_000 // 1 Mbps
|
|
case "medium": return 3_000_000 // 3 Mbps
|
|
case "high": return 6_000_000 // 6 Mbps
|
|
case "highest": return 10_000_000 // 10 Mbps
|
|
default: return 6_000_000
|
|
}
|
|
}
|
|
|
|
/// Clamp FPS to a sane range (ported from classic ScreenRecordService).
|
|
private static func clampFps(_ fps: Double) -> Double {
|
|
if !fps.isFinite { return 30 }
|
|
return min(60, max(1, fps))
|
|
}
|
|
|
|
private func permissionString(from status: AVAuthorizationStatus) -> String {
|
|
switch status {
|
|
case .authorized: return "granted"
|
|
case .denied, .restricted: return "denied"
|
|
default: return "prompt"
|
|
}
|
|
}
|
|
}
|