From 004ba5d786779901bc3c5f1079a2a7029f1a2289 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Mon, 11 Nov 2024 17:41:05 -0500 Subject: [PATCH 01/27] Rewrite using new Aperture and n-api --- .gitignore | 5 + .swiftlint.yml | 6 + Package.resolved | 17 +- Package.swift | 20 +- Sources/ApertureCLI/ApertureCLI.swift | 97 +++++- Sources/ApertureCLI/Utilities.swift | 11 + Sources/ApertureCLI/record.swift | 75 +++-- Sources/ApertureModule/ApertureModule.swift | 310 ++++++++++++++++++++ common.js | 104 +++++++ index.d.ts | 192 ++++++++++-- index.js | 164 +++++------ index.test-d.ts | 24 +- native.js | 146 +++++++++ native.test.js | 54 ++++ package.json | 32 +- test.js | 5 +- 16 files changed, 1102 insertions(+), 160 deletions(-) create mode 100644 .swiftlint.yml create mode 100644 Sources/ApertureModule/ApertureModule.swift create mode 100644 common.js create mode 100644 native.js create mode 100644 native.test.js diff --git a/.gitignore b/.gitignore index 7c83b10..da4a8b2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,10 @@ xcuserdata /Packages /*.xcodeproj /aperture +/aperture.node recording.mp4 + + +# SwiftLint Remote Config Cache +.swiftlint/RemoteConfigCache \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1432074 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,6 @@ +parent_config: https://raw.githubusercontent.com/sindresorhus/swiftlint-config/main/.swiftlint.yml +deployment_target: + macOS_deployment_target: '13' +excluded: + - .build + - node_modules \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index db40f48..44560f8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wulkano/Aperture", "state" : { - "revision" : "ddfb0fc1b3c789339dd5fd9296ba8076d292611c", - "version" : "2.0.1" + "branch" : "george/rewrite-in-screen-capture-kit", + "revision" : "6a3adffa8b3af3fd766e581bddf2c4416bf4547a" } }, { @@ -14,8 +14,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } } ], diff --git a/Package.swift b/Package.swift index 8a9be6f..453b8a5 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "ApertureCLI", platforms: [ - .macOS(.v10_13) + .macOS(.v13) ], products: [ .executable( @@ -12,11 +12,17 @@ let package = Package( targets: [ "ApertureCLI" ] + ), + .library( + name: "aperture-module", + type: .dynamic, + targets: ["ApertureModule"] ) ], dependencies: [ - .package(url: "https://github.com/wulkano/Aperture", from: "2.0.1"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1") + .package(url: "https://github.com/wulkano/Aperture", branch: "george/rewrite-in-screen-capture-kit"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"), + .package(path: "node_modules/node-swift") ], targets: [ .executableTarget( @@ -25,6 +31,14 @@ let package = Package( "Aperture", .product(name: "ArgumentParser", package: "swift-argument-parser") ] + ), + .target( + name: "ApertureModule", + dependencies: [ + "Aperture", + .product(name: "NodeAPI", package: "node-swift"), + .product(name: "NodeModuleSupport", package: "node-swift") + ] ) ] ) diff --git a/Sources/ApertureCLI/ApertureCLI.swift b/Sources/ApertureCLI/ApertureCLI.swift index 94fb4d7..42518a2 100644 --- a/Sources/ApertureCLI/ApertureCLI.swift +++ b/Sources/ApertureCLI/ApertureCLI.swift @@ -10,6 +10,13 @@ enum OutEvent: String, CaseIterable, ExpressibleByArgument { case onFinish } +enum TargetType: String, CaseIterable, ExpressibleByArgument { + case screen + case window + case audio + case externalDevice +} + enum InEvent: String, CaseIterable, ExpressibleByArgument { case pause case resume @@ -40,7 +47,9 @@ extension ApertureCLI { static let configuration = CommandConfiguration( subcommands: [ Screens.self, - AudioDevices.self + AudioDevices.self, + Windows.self, + ExternalDevices.self ] ) } @@ -51,11 +60,23 @@ extension ApertureCLI { @Option(name: .shortAndLong, help: "The ID to use for this process") var processId = "main" + @Option(name: .shortAndLong, help: "The type of target to record") + var targetType = TargetType.screen + @Argument(help: "Stringified JSON object with options passed to Aperture") var options: String mutating func run() throws { - try record(options, processId: processId) + Task { [self] in + do { + try await record(options, processId: processId, targetType: targetType) + } catch { + print(error, to: .standardError) + Darwin.exit(1) + } + } + + RunLoop.main.run() } } @@ -75,8 +96,67 @@ extension ApertureCLI.List { static let configuration = CommandConfiguration(abstract: "List available screens.") mutating func run() throws { - // Uses stderr because of unrelated stuff being outputted on stdout. - print(try toJson(Aperture.Devices.screen().map { ["id": $0.id, "name": $0.name] }), to: .standardError) + Task { + // Uses stderr because of unrelated stuff being outputted on stdout. + print( + try toJson( + await Aperture.Devices.screen().map { + [ + "id": $0.id, + "name": $0.name, + "width": $0.width, + "height": $0.height, + "frame": $0.frame.asDictionary + ] + } + ), + to: .standardError + ) + Darwin.exit(0) + } + + RunLoop.main.run() + } + } + + struct Windows: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List available windows.") + + @Flag(inversion: .prefixedNo, help: "Exclude desktop windows") + var excludeDesktopWindows = true + + @Flag(inversion: .prefixedNo, help: "Only include windows that are on screen") + var onScreenOnly = true + + mutating func run() throws { + Task { [self] in + // Uses stderr because of unrelated stuff being outputted on stdout. + print( + try toJson( + await Aperture.Devices.window( + excludeDesktopWindows: excludeDesktopWindows, + onScreenWindowsOnly: onScreenOnly + ) + .map { + [ + "id": $0.id, + "title": $0.title as Any, + "applicationName": $0.applicationName as Any, + "applicationBundleIdentifier": $0.applicationBundleIdentifier as Any, + "isActive": $0.isActive, + "isOnScreen": $0.isOnScreen, + "layer": $0.layer, + "frame": $0.frame.asDictionary + ] + } + ), + to: .standardError + ) + + Darwin.exit(0) + } + + RunLoop.main.run() } } @@ -88,6 +168,15 @@ extension ApertureCLI.List { print(try toJson(Aperture.Devices.audio().map { ["id": $0.id, "name": $0.name] }), to: .standardError) } } + + struct ExternalDevices: ParsableCommand { + static let configuration = CommandConfiguration(abstract: "List available external devices.") + + mutating func run() throws { + // Uses stderr because of unrelated stuff being outputted on stdout. + print(try toJson(Aperture.Devices.iOS().map { ["id": $0.id, "name": $0.name] }), to: .standardError) + } + } } extension ApertureCLI.Events { diff --git a/Sources/ApertureCLI/Utilities.swift b/Sources/ApertureCLI/Utilities.swift index 4033527..326d527 100644 --- a/Sources/ApertureCLI/Utilities.swift +++ b/Sources/ApertureCLI/Utilities.swift @@ -260,3 +260,14 @@ func toJson(_ data: T) throws -> String { return String(data: json, encoding: .utf8)! } // MARK: - + +extension CGRect { + var asDictionary: [String: Any] { + [ + "x": Int(origin.x), + "y": Int(origin.y), + "width": Int(size.width), + "height": Int(size.height) + ] + } +} diff --git a/Sources/ApertureCLI/record.swift b/Sources/ApertureCLI/record.swift index a815614..7c509d0 100644 --- a/Sources/ApertureCLI/record.swift +++ b/Sources/ApertureCLI/record.swift @@ -3,30 +3,22 @@ import Aperture struct Options: Decodable { let destination: URL + let targetId: String? let framesPerSecond: Int let cropRect: CGRect? let showCursor: Bool let highlightClicks: Bool - let screenId: CGDirectDisplayID let audioDeviceId: String? let videoCodec: String? + let losslessAudio: Bool + let recordSystemAudio: Bool } -func record(_ optionsString: String, processId: String) throws { - setbuf(__stdoutp, nil) +func record(_ optionsString: String, processId: String, targetType: TargetType) async throws { let options: Options = try optionsString.jsonDecoded() var observers = [Any]() - let recorder = try Aperture( - destination: options.destination, - framesPerSecond: options.framesPerSecond, - cropRect: options.cropRect, - showCursor: options.showCursor, - highlightClicks: options.highlightClicks, - screenId: options.screenId == 0 ? .main : options.screenId, - audioDevice: options.audioDeviceId != nil ? AVCaptureDevice(uniqueID: options.audioDeviceId!) : nil, - videoCodec: options.videoCodec != nil ? AVVideoCodecType(rawValue: options.videoCodec!) : nil - ) + let recorder = Aperture.Recorder() recorder.onStart = { ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFileReady.rawValue) @@ -40,16 +32,12 @@ func record(_ optionsString: String, processId: String) throws { ApertureEvents.sendEvent(processId: processId, event: OutEvent.onResume.rawValue) } - recorder.onFinish = { - switch $0 { - case .success(_): - // TODO: Handle warning on the JS side. - break - case .failure(let error): - print(error, to: .standardError) - exit(1) - } + recorder.onError = { + print($0, to: .standardError) + exit(1) + } + recorder.onFinish = { ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFinish.rawValue) for observer in observers { @@ -60,7 +48,9 @@ func record(_ optionsString: String, processId: String) throws { } CLI.onExit = { - recorder.stop() + Task { + try await recorder.stopRecording() + } // Do not call `exit()` here as the video is not always done // saving at this point and will be corrupted randomly } @@ -83,8 +73,41 @@ func record(_ optionsString: String, processId: String) throws { } ) - recorder.start() - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onStart.rawValue) + let videoCodec: Aperture.VideoCodec + if let videoCodecString = options.videoCodec { + videoCodec = try .fromRawValue(videoCodecString) + } else { + videoCodec = .h264 + } + + let target: Aperture.Target + + switch targetType { + case .screen: + target = .screen + case .window: + target = .window + case .audio: + target = .audioOnly + case .externalDevice: + target = .externalDevice + } - RunLoop.main.run() + try await recorder.startRecording( + target: target, + options: Aperture.RecordingOptions( + destination: options.destination, + targetID: options.targetId, + framesPerSecond: options.framesPerSecond, + cropRect: options.cropRect, + showCursor: options.showCursor, + highlightClicks: options.highlightClicks, + videoCodec: videoCodec, + losslessAudio: options.losslessAudio, + recordSystemAudio: options.recordSystemAudio, + microphoneDeviceID: options.audioDeviceId != nil ? options.audioDeviceId : nil + ) + ) + + ApertureEvents.sendEvent(processId: processId, event: OutEvent.onStart.rawValue) } diff --git a/Sources/ApertureModule/ApertureModule.swift b/Sources/ApertureModule/ApertureModule.swift new file mode 100644 index 0000000..22512f3 --- /dev/null +++ b/Sources/ApertureModule/ApertureModule.swift @@ -0,0 +1,310 @@ +import NodeAPI +import Aperture +import Foundation +import AVFoundation + + + +@NodeClass final class Recorder { + @NodeActor + private var recorder: Aperture.Recorder + + private let nodeQueue: NodeAsyncQueue + + @NodeActor + @NodeConstructor + init () throws { + self.recorder = Aperture.Recorder() + self.nodeQueue = try NodeAsyncQueue(label: "Node Queue") + } + + @NodeActor + @NodeMethod + func startRecording(_ targetString: NodeString, _ options: NodeObject) async throws { + let target: Aperture.Target + + switch try targetString.string() { + case "screen": + target = .screen + case "window": + target = .window + case "audioOnly": + target = .audioOnly + case "externalDevice": + target = .externalDevice + default: + throw try NodeError(code: nil, message: "Invalid value provided for target. screen, window, audioOnly or externalDevice expected.") + } + + try await self.recorder.startRecording(target: target, options: options.asOptions()) + } + + @NodeActor + @NodeMethod + func stopRecording() async throws { + try await self.recorder.stopRecording() + } + + @NodeActor + @NodeMethod + func pause() { + self.recorder.pause() + } + + @NodeActor + @NodeMethod + func resume() { + self.recorder.resume() + } + + @NodeActor + @NodeMethod + func isPaused() async -> Bool { + self.recorder.isPaused + } + + @NodeActor private var _onFinish: NodeFunction? + @NodeActor @NodeProperty var onFinish: NodeFunction? { + get { + _onFinish + } + set { + _onFinish = newValue + + if let newValue { + self.recorder.onFinish = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onFinish = nil + } + } + } + + @NodeActor private var _onStart: NodeFunction? + @NodeActor @NodeProperty var onStart: NodeFunction? { + get { + _onStart + } + set { + _onStart = newValue + + if let newValue { + self.recorder.onStart = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onStart = nil + } + } + } + + @NodeActor private var _onPause: NodeFunction? + @NodeActor @NodeProperty var onPause: NodeFunction? { + get { + _onPause + } + set { + _onPause = newValue + + if let newValue { + self.recorder.onPause = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onPause = nil + } + } + } + + @NodeActor private var _onResume: NodeFunction? + @NodeActor @NodeProperty var onResume: NodeFunction? { + get { + _onResume + } + set { + _onResume = newValue + + if let newValue { + self.recorder.onResume = { + try? self.nodeQueue.run { + _ = try? newValue.call([]) + } + } + } else { + self.recorder.onResume = nil + } + } + } + + @NodeActor private var _onError: NodeFunction? + @NodeActor @NodeProperty var onError: NodeFunction? { + get { + _onError + } + set { + _onError = newValue + + if let newValue { + self.recorder.onError = { error in + try? self.nodeQueue.run { + _ = try? newValue.call([ + try NodeError(code: nil, message: error.localizedDescription) + ]) + } + } + } else { + self.recorder.onError = nil + } + } + } +} + +#NodeModule(exports: [ + "getScreens": try NodeFunction { () async throws in + let screens = try await Aperture.Devices.screen() + return screens as [any NodeValueConvertible] + }, + "getWindows": try NodeFunction { (excludeDesktopWindows: Bool, onScreenWindowsOnly: Bool) async throws in + let windows = try await Aperture.Devices.window(excludeDesktopWindows: excludeDesktopWindows, onScreenWindowsOnly: onScreenWindowsOnly) + return windows as [any NodeValueConvertible] + }, + "getAudioDevices": try NodeFunction { () async in + let audioDevices = Aperture.Devices.audio() + return audioDevices as [any NodeValueConvertible] + }, + "getIOSDevices": try NodeFunction { () async in + let iosDevices = Aperture.Devices.iOS() + return iosDevices as [any NodeValueConvertible] + }, + "Recorder": Recorder.deferredConstructor +]) + +extension NodeObject { + func getAs(_ name: String, type: T.Type) throws -> T? { + if try self.hasOwnProperty(name) { + guard let value = try self[name].as(T.self) else { + throw try NodeError(code: nil, message: "Invalid value provided for \(name). \(type) expected.") + } + + return value + } + return nil + } + + func getAsRequired(_ name: String, type: T.Type, errorMessage: String? = nil) throws -> T { + guard let value = try getAs(name, type: type.self) else { + throw try NodeError(code: nil, message: "\(name) is required") + } + + return value + } +} + +extension NodeObject { + func asCGRect() throws -> CGRect { + CGRect( + origin: CGPoint( + x: try getAsRequired("x", type: Int.self), + y: try getAsRequired("y", type: Int.self) + ), + size: CGSize( + width: try getAsRequired("width", type: Int.self), + height: try getAsRequired("height", type: Int.self) + ) + ) + } + + func asOptions() throws -> Aperture.RecordingOptions { + let destinationPath = try self.getAsRequired("destination", type: String.self) + let destination = URL(fileURLWithPath: destinationPath) + + let videoCodecString = try getAs("videoCodec", type: String.self) + let videoCodec: Aperture.VideoCodec? + + if let videoCodecString { + do { + videoCodec = try .fromRawValue(videoCodecString) + } catch { + throw try NodeError(code: nil, message: "Invalid value provided for videoCodec. h264, hevc, proRes422 or proRes4444 expected.") + } + } else { + videoCodec = nil + } + + return Aperture.RecordingOptions( + destination: destination, + targetID: try getAs("targetId", type: String.self), + framesPerSecond: try getAs("framesPerSecond", type: Int.self) ?? 60, + cropRect: try getAs("cropRect", type: NodeObject.self)?.asCGRect(), + showCursor: try getAs("showCursor", type: Bool.self) ?? true, + highlightClicks: try getAs("highlightClicks", type: Bool.self) ?? false, + videoCodec: videoCodec ?? .h264, + losslessAudio: try getAs("losslessAudio", type: Bool.self) ?? false, + recordSystemAudio: try getAs("recordSystemAudio", type: Bool.self) ?? false, + microphoneDeviceID: try getAs("microphoneDeviceID", type: String.self) + ) + } +} + +extension CGRect: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "x": Int(self.origin.x), + "y": Int(self.origin.y), + "width": Int(self.size.width), + "height": Int(self.size.height) + ]) + } +} + +extension Aperture.Devices.Screen: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "name": self.name, + "width": self.width, + "height": self.height, + "frame": self.frame.nodeValue() + ]) + } +} + +extension Aperture.Devices.Window: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "title": self.title, + "frame": self.frame.nodeValue(), + "applicationName": self.applicationName, + "applicationBundleIdentifier": self.applicationBundleIdentifier, + "isActive": self.isActive, + "isOnScreen": self.isOnScreen, + "layer": self.layer + ]) + } +} + +extension Aperture.Devices.Audio: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "name": self.name + ]) + } +} + +extension Aperture.Devices.IOS: @retroactive NodeValueConvertible { + public func nodeValue() throws -> any NodeValue { + try NodeObject([ + "id": String(self.id), + "name": self.name + ]) + } +} diff --git a/common.js b/common.js new file mode 100644 index 0000000..539e5c8 --- /dev/null +++ b/common.js @@ -0,0 +1,104 @@ +import os from 'node:os'; +import {temporaryFile} from 'tempy'; +import fileUrl from 'file-url'; + +export const supportsHevcHardwareEncoding = (() => { + const cpuModel = os.cpus()[0].model; + + // All Apple silicon Macs support HEVC hardware encoding. + if (cpuModel.startsWith('Apple ')) { + // Source string example: `'Apple M1'` + return true; + } + + // Get the Intel Core generation, the `4` in `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` + // More info: https://www.intel.com/content/www/us/en/processors/processor-numbers.html + // Example strings: + // - `Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz` + // - `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` + const result = /Intel.*Core.*i\d+-(\d)/.exec(cpuModel); + + // Intel Core generation 6 or higher supports HEVC hardware encoding + return result && Number.parseInt(result[1], 10) >= 6; +})(); + +export const videoCodecs = new Map([ + ['h264', 'H264'], + ['hevc', 'HEVC'], + ['proRes422', 'Apple ProRes 422'], + ['proRes4444', 'Apple ProRes 4444'], +]); + +if (!supportsHevcHardwareEncoding) { + videoCodecs.delete('hevc'); +} + +export function normalizeOptions(targetType, { + targetId = undefined, + fps = 30, + cropArea = undefined, + showCursor = true, + highlightClicks = false, + audioDeviceId = undefined, + videoCodec = 'h264', + losslessAudio = false, + systemAudio = false, + extension = videoCodec === 'proRes422' || videoCodec === 'proRes4444' ? 'mov' : 'mp4', +} = {}) { + const recorderOptions = { + targetId, + framesPerSecond: fps, + showCursor, + highlightClicks, + audioDeviceId, + losslessAudio, + recordSystemAudio: systemAudio, + }; + + if (videoCodec && targetType !== 'audio') { + const codecMap = new Map([ + ['h264', ['mp4', 'mov', 'm4v']], + ['hevc', ['mp4', 'mov', 'm4v']], + ['proRes422', ['mov']], + ['proRes4444', ['mov']], + ]); + + if (!supportsHevcHardwareEncoding) { + codecMap.delete('hevc'); + } + + const allowedExtensions = codecMap.get(videoCodec); + + if (!allowedExtensions) { + throw new Error(`Unsupported video codec specified: ${videoCodec}`); + } + + if (!allowedExtensions.includes(extension)) { + throw new Error(`The video codec ${videoCodec} does not support the extension ${extension}. Allowed extensions: ${allowedExtensions.join(', ')}`); + } + + recorderOptions.videoCodec = videoCodec; + } + + const temporaryPath = temporaryFile({ + extension: targetType === 'audio' ? 'm4a' : extension, + }); + + recorderOptions.destination = fileUrl(temporaryPath); + + if (highlightClicks === true) { + recorderOptions.showCursor = true; + } + + if (targetType === 'screen' && cropArea) { + recorderOptions.cropRect = [ + [cropArea.x, cropArea.y], + [cropArea.width, cropArea.height], + ]; + } + + return { + tmpPath: temporaryPath, + recorderOptions, + }; +} diff --git a/index.d.ts b/index.d.ts index 51162c6..c54b93d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,6 +1,29 @@ +import {type RequireAtLeastOne} from 'type-fest'; + +export type Frame = { + x: number; + y: number; + width: number; + height: number; +}; + export type Screen = { - id: number; + id: string; name: string; + width: number; + height: number; + frame: Frame; +}; + +export type Window = { + id: string; + title?: string; + applicationName?: string; + applicationBundleIdentifier?: string; + isActive: boolean; + isOnScreen: boolean; + layer: number; + frame: Frame; }; export type AudioDevice = { @@ -8,23 +31,37 @@ export type AudioDevice = { name: string; }; +export type ExternalDevice = { + id: string; + name: string; +}; + export type VideoCodec = 'h264' | 'hevc' | 'proRes422' | 'proRes4444'; -export type RecordingOptions = { +export type AudioRecordingOptions = { /** - Number of frames per seconds. + Audio device to include in the screen recording. + + Should be one of the `id`'s from `audioDevices()`. */ - readonly fps?: number; + readonly audioDeviceId?: string; + + /** + Record audio in a lossless format. + */ + readonly losslessAudio?: boolean; /** - Record only an area of the screen. + Record system audio. + */ + readonly systemAudio?: boolean; +}; + +export type VideoRecordingOptions = AudioRecordingOptions & { + /** + Number of frames per seconds. */ - readonly cropArea?: { - x: number; - y: number; - width: number; - height: number; - }; + readonly fps?: number; /** Show the cursor in the screen recording. @@ -39,34 +76,74 @@ export type RecordingOptions = { readonly highlightClicks?: boolean; /** - Screen to record. + Video codec to use. + + A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. - Defaults to primary screen. + The `proRes422` and `proRes4444` codecs are uncompressed data. They will create huge files. */ - readonly screenId?: number; + readonly videoCodec?: Codec; /** - Audio device to include in the screen recording. + The extension of the output file. - Should be one of the `id`'s from `audioDevices()`. + The `proRes422` and `proRes4444` codecs only support the `mov` extension. */ - readonly audioDeviceId?: string; + readonly extension?: Codec extends 'proRes422' | 'proRes4444' ? 'mov' : ('mp4' | 'mov' | 'm4v'); +}; +export declare class Recorder { /** - Video codec to use. + Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. + */ + startRecordingScreen: ( + options: VideoRecordingOptions & { + /** + The id of the screen to record. + + Should be one of the `id`'s from `screens()`. + */ + readonly screenId: string; + + /** + Record only an area of the screen. + */ + readonly cropArea?: Frame; + } + ) => Promise; - A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. + /** + Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. + */ + startRecordingWindow: ( + options: VideoRecordingOptions & { + /** + The id of the screen to record. - The `proRes422` and `proRes4444` codecs are uncompressed data. They will create huge files. + Should be one of the `id`'s from `windows()`. + */ + readonly windowId: string; + } + ) => Promise; + + /** + Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. */ - readonly videoCodec?: VideoCodec; -}; + startRecordingExternalDevice: ( + options: Omit, 'showCursor' | 'highlightClicks'> & { + /** + The id of the screen to record. + + Should be one of the `id`'s from `extranlDevices()`. + */ + readonly deviceId: string; + } + ) => Promise; -export type Recorder = { /** Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. */ - startRecording: (options?: RecordingOptions) => Promise; + startRecordingAudio: (options: RequireAtLeastOne) => Promise; /** `Promise` that fullfills with the path to the screen recording file when it's ready. This will never reject. @@ -100,7 +177,7 @@ export type Recorder = { Returns a `Promise` for the path to the screen recording file. */ stopRecording: () => Promise; -}; +} /** Get a list of available video codecs. @@ -129,13 +206,61 @@ The first screen is the primary screen. @example ``` [{ - id: 69732482, - name: 'Color LCD' + id: '69732482', + name: 'Color LCD', + width: 1280, + height: 800, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } }] ``` */ export function screens(): Promise; +export type WindowOptions = { + /** + Exclude desktop windows like Finder, Dock, and Desktop. + + @default true + */ + readonly excludeDesktopWindows?: boolean; + + /** + Only include windows that are on screen. + + @default true + */ + readonly onScreenOnly?: boolean; +}; + +/** +Get a list of windows. + +@example +``` +[{ + id: '69732482', + title: 'Unicorn', + applicationName: 'Safari', + applicationBundleIdentifier: 'com.apple.Safari', + isActive: true, + isOnScreen: true, + layer: 0, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } +}] +``` +*/ +export function windows(options?: WindowOptions): Promise; + /** Get a list of audio devices. @@ -149,4 +274,17 @@ Get a list of audio devices. */ export function audioDevices(): Promise; +/** +Get a list of external devices. + +@example +``` +[{ + id: '9eb08da55a14244bf8044bf0f75247d2cb9c364c', + name: 'iPad Pro' +}] +``` +*/ +export function externalDevices(): Promise; + export const recorder: Recorder; diff --git a/index.js b/index.js index 65bdda6..8b12045 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,13 @@ -import os from 'node:os'; import {debuglog} from 'node:util'; import path from 'node:path'; import url from 'node:url'; import {execa} from 'execa'; -import {temporaryFile} from 'tempy'; import {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; -import fileUrl from 'file-url'; import {fixPathForAsarUnpack} from 'electron-util/node'; import delay from 'delay'; +import {normalizeOptions} from './common.js'; + +export {videoCodecs} from './common.js'; const log = debuglog('aperture'); const getRandomId = () => Math.random().toString(36).slice(2, 15); @@ -16,40 +16,55 @@ const dirname_ = path.dirname(url.fileURLToPath(import.meta.url)); // Workaround for https://github.com/electron/electron/issues/9459 const BINARY = path.join(fixPathForAsarUnpack(dirname_), 'aperture'); -const supportsHevcHardwareEncoding = (() => { - const cpuModel = os.cpus()[0].model; +export class Recorder { + constructor() { + assertMacOSVersionGreaterThanOrEqualTo('13'); + } - // All Apple silicon Macs support HEVC hardware encoding. - if (cpuModel.startsWith('Apple ')) { - // Source string example: `'Apple M1'` - return true; + startRecordingScreen({ + screenId, + ...options + }) { + return this._startRecording('screen', { + ...options, + targetId: screenId, + }); } - // Get the Intel Core generation, the `4` in `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` - // More info: https://www.intel.com/content/www/us/en/processors/processor-numbers.html - // Example strings: - // - `Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz` - // - `Intel(R) Core(TM) i7-4850HQ CPU @ 2.30GHz` - const result = /Intel.*Core.*i\d+-(\d)/.exec(cpuModel); + startRecordingWindow({ + windowId, + ...options + }) { + return this._startRecording('window', { + ...options, + targetId: windowId, + }); + } - // Intel Core generation 6 or higher supports HEVC hardware encoding - return result && Number.parseInt(result[1], 10) >= 6; -})(); + startRecordingExternalDevice({ + deviceId, + ...options + }) { + return this._startRecording('externalDevice', { + ...options, + targetId: deviceId, + }); + } -class Recorder { - constructor() { - assertMacOSVersionGreaterThanOrEqualTo('10.13'); + startRecordingAudio({ + audioDeviceId, + losslessAudio, + systemAudio, + }) { + return this._startRecording('audio', { + audioDeviceId, + losslessAudio, + systemAudio, + extension: 'm4a', + }); } - startRecording({ - fps = 30, - cropArea = undefined, - showCursor = true, - highlightClicks = false, - screenId = 0, - audioDeviceId = undefined, - videoCodec = 'h264', - } = {}) { + _startRecording(targetType, options) { this.processId = getRandomId(); return new Promise((resolve, reject) => { @@ -58,57 +73,9 @@ class Recorder { return; } - this.tmpPath = temporaryFile({extension: 'mp4'}); - - if (highlightClicks === true) { - showCursor = true; - } - - if ( - typeof cropArea === 'object' - && (typeof cropArea.x !== 'number' - || typeof cropArea.y !== 'number' - || typeof cropArea.width !== 'number' - || typeof cropArea.height !== 'number') - ) { - reject(new Error('Invalid `cropArea` option object')); - return; - } - - const recorderOptions = { - destination: fileUrl(this.tmpPath), - framesPerSecond: fps, - showCursor, - highlightClicks, - screenId, - audioDeviceId, - }; - - if (cropArea) { - recorderOptions.cropRect = [ - [cropArea.x, cropArea.y], - [cropArea.width, cropArea.height], - ]; - } - - if (videoCodec) { - const codecMap = new Map([ - ['h264', 'avc1'], - ['hevc', 'hvc1'], - ['proRes422', 'apcn'], - ['proRes4444', 'ap4h'], - ]); - - if (!supportsHevcHardwareEncoding) { - codecMap.delete('hevc'); - } + const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); - if (!codecMap.has(videoCodec)) { - throw new Error(`Unsupported video codec specified: ${videoCodec}`); - } - - recorderOptions.videoCodec = codecMap.get(videoCodec); - } + this.tmpPath = tmpPath; const timeout = setTimeout(() => { // `.stopRecording()` was called already @@ -142,6 +109,8 @@ class Recorder { 'record', '--process-id', this.processId, + '--target-type', + targetType, JSON.stringify(recorderOptions), ]); @@ -237,6 +206,24 @@ export const screens = async () => { } }; +export const windows = async ({ + excludeDesktopWindows = true, + onScreenOnly = true, +} = {}) => { + const {stderr} = await execa(BINARY, [ + 'list', + 'windows', + excludeDesktopWindows ? '--exclude-desktop-windows' : '--no-exclude-desktop-windows', + onScreenOnly ? '--on-screen-only' : '--no-on-screen-only', + ]); + + try { + return JSON.parse(removeWarnings(stderr)); + } catch (error) { + throw new Error(stderr, {cause: error}); + } +}; + export const audioDevices = async () => { const {stderr} = await execa(BINARY, ['list', 'audio-devices']); @@ -247,13 +234,12 @@ export const audioDevices = async () => { } }; -export const videoCodecs = new Map([ - ['h264', 'H264'], - ['hevc', 'HEVC'], - ['proRes422', 'Apple ProRes 422'], - ['proRes4444', 'Apple ProRes 4444'], -]); +export const externalDevices = async () => { + const {stderr} = await execa(BINARY, ['list', 'external-devices']); -if (!supportsHevcHardwareEncoding) { - videoCodecs.delete('hevc'); -} + try { + return JSON.parse(removeWarnings(stderr)); + } catch (error) { + throw new Error(stderr, {cause: error}); + } +}; diff --git a/index.test-d.ts b/index.test-d.ts index d199919..113e40e 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -16,7 +16,29 @@ expectType(await audioDevices()); expectType(await screens()); -expectError(recorder.startRecording({videoCodec: 'random'})); +expectError(recorder.startRecordingScreen({})); + +expectError(recorder.startRecordingScreen({screenId: '1', videoCodec: 'random'})); + +expectError(recorder.startRecordingScreen({screenId: '1', videoCodec: 'proRes422', extension: 'mp4'})); + +expectType>(recorder.startRecordingScreen({screenId: '1', videoCodec: 'proRes422', extension: 'mov'})); + +expectType>(recorder.startRecordingScreen({screenId: '1', extension: 'mp4'})); + +expectType>(recorder.startRecordingScreen({screenId: '1'})); + +expectError(recorder.startRecordingWindow({})); + +expectType>(recorder.startRecordingWindow({windowId: '1'})); + +expectError(recorder.startRecordingExternalDevice({})); + +expectType>(recorder.startRecordingExternalDevice({deviceId: '1'})); + +expectError(recorder.startRecordingAudio({losslessAudio: true})); + +expectType>(recorder.startRecordingAudio({systemAudio: true, audioDeviceId: '1'})); expectType(await recorder.isFileReady); diff --git a/native.js b/native.js new file mode 100644 index 0000000..f2ae93c --- /dev/null +++ b/native.js @@ -0,0 +1,146 @@ +import {createRequire} from 'node:module'; +import {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; +import {normalizeOptions} from './common.js'; + +export {videoCodecs} from './common.js'; + +const nativeModule = createRequire(import.meta.url)('./aperture.node'); + +export class Recorder { + constructor() { + assertMacOSVersionGreaterThanOrEqualTo('13'); + } + + startRecordingScreen({ + screenId, + ...options + }) { + return this._startRecording('screen', { + ...options, + targetId: screenId, + }); + } + + startRecordingWindow({ + windowId, + ...options + }) { + return this._startRecording('window', { + ...options, + targetId: windowId, + }); + } + + startRecordingExternalDevice({ + deviceId, + ...options + }) { + return this._startRecording('externalDevice', { + ...options, + targetId: deviceId, + }); + } + + startRecordingAudio({ + audioDeviceId, + losslessAudio, + systemAudio, + }) { + return this._startRecording('audio', { + audioDeviceId, + losslessAudio, + systemAudio, + extension: 'm4a', + }); + } + + async _startRecording(targetType, options) { + if (this.recorder !== undefined) { + throw new Error('Call `.stopRecording()` first'); + } + + const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); + + this.tmpPath = tmpPath; + this.recorder = new nativeModule.Recorder(); + + this.isFileReady = new Promise(resolve => { + this.recorder.onStart = () => { + resolve(this.tmpPath); + }; + }); + + const finalOptions = { + destination: tmpPath, + framesPerSecond: recorderOptions.framesPerSecond, + showCursor: recorderOptions.showCursor, + highlightClicks: recorderOptions.highlightClicks, + losslessAudio: recorderOptions.losslessAudio, + recordSystemAudio: recorderOptions.recordSystemAudio, + }; + + if (recorderOptions.videoCodec) { + finalOptions.videoCodec = recorderOptions.videoCodec; + } + + if (targetType === 'screen' && options.cropArea) { + finalOptions.cropRect = options.cropArea; + } + + if (recorderOptions.targetId) { + finalOptions.targetId = recorderOptions.targetId; + } + + if (recorderOptions.audioDeviceId) { + finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; + } + + console.log(finalOptions); + + await this.recorder.startRecording(targetType === 'audio' ? 'audioOnly' : targetType, finalOptions); + } + + throwIfNotStarted() { + if (this.recorder === undefined) { + throw new Error('Call `.startRecording()` first'); + } + } + + async pause() { + this.throwIfNotStarted(); + this.recorder.pause(); + } + + async resume() { + this.throwIfNotStarted(); + this.recorder.resume(); + } + + async isPaused() { + this.throwIfNotStarted(); + return this.recorder.isPaused(); + } + + async stopRecording() { + this.throwIfNotStarted(); + await this.recorder.stopRecording(); + + delete this.recorder; + delete this.isFileReady; + + return this.tmpPath; + } +} + +export const recorder = new Recorder(); + +export const screens = async () => nativeModule.getScreens(); + +export const windows = async ({ + excludeDesktopWindows = true, + onScreenOnly = true, +} = {}) => nativeModule.getWindows(excludeDesktopWindows, onScreenOnly); + +export const audioDevices = async () => nativeModule.getAudioDevices(); + +export const externalDevices = async () => nativeModule.getIOSDevices(); diff --git a/native.test.js b/native.test.js new file mode 100644 index 0000000..fbdf218 --- /dev/null +++ b/native.test.js @@ -0,0 +1,54 @@ +import fs from 'node:fs'; +import test from 'ava'; +import delay from 'delay'; +import {fileTypeFromBuffer} from 'file-type'; +import {readChunk} from 'read-chunk'; +import { + recorder, + audioDevices, + screens, + videoCodecs, +} from './native.js'; + +test('returns audio devices', async t => { + const devices = await audioDevices(); + console.log('Audio devices:', devices); + + t.true(Array.isArray(devices)); + + if (devices.length > 0) { + t.true(devices[0].id.length > 0); + t.true(devices[0].name.length > 0); + } +}); + +test('returns screens', async t => { + const monitors = await screens(); + console.log('Screens:', monitors); + + t.true(Array.isArray(monitors)); + + if (monitors.length > 0) { + t.true(monitors[0].id > 0); + t.true(monitors[0].name.length > 0); + } +}); + +test('returns available video codecs', t => { + const codecs = videoCodecs; + console.log('Video codecs:', codecs); + t.true(codecs.has('h264')); +}); + +test('records screen', async t => { + const monitors = await screens(); + await recorder.startRecordingScreen({screenId: monitors[0].id}); + t.true(fs.existsSync(await recorder.isFileReady)); + await delay(1000); + const videoPath = await recorder.stopRecording(); + t.true(fs.existsSync(videoPath)); + const buffer = await readChunk(videoPath, {length: 4100}); + const fileType = await fileTypeFromBuffer(buffer); + t.is(fileType.ext, 'mp4'); + fs.unlinkSync(videoPath); +}); diff --git a/package.json b/package.json index 44a80ba..19b301a 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,14 @@ "repository": "wulkano/aperture-node", "type": "module", "exports": { - "types": "./index.d.ts", - "default": "./index.js" + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./native": { + "types": "./index.d.ts", + "default": "./native.js" + } }, "sideEffects": false, "engines": { @@ -15,13 +21,23 @@ }, "scripts": { "test": "xo && ava && tsd", - "build": "swift build --configuration=release --arch arm64 --arch x86_64 && mv .build/apple/Products/Release/aperture .", + "build": "npm run build:cli && npm run build:module", + "build:cli": "npm run build:cli:build && npm run build:cli:move", + "build:cli:build": "swift build --configuration=release --product aperture --arch arm64 --arch x86_64", + "build:cli:move": "mv .build/apple/Products/Release/aperture .", + "build:module": "npm run build:module:build && npm run build:module:move && npm run build:module:sign", + "build:module:build": "swift build -c release --product aperture-module -Xlinker -undefined -Xlinker dynamic_lookup", + "build:module:move": "mv .build/arm64-apple-macosx/release/libaperture-module.dylib ./aperture.node", + "build:module:sign": "codesign -fs - ./aperture.node", "prepublish": "npm run build" }, "files": [ "index.js", + "index.d.ts", + "common.js", + "native.js", "aperture", - "index.d.ts" + "aperture.node" ], "dependencies": { "delay": "^6.0.0", @@ -34,8 +50,16 @@ "devDependencies": { "ava": "^6.1.2", "file-type": "^19.0.0", + "node-swift": "github:kabiroberai/node-swift#1.3.0", "read-chunk": "^4.0.3", "tsd": "^0.30.7", + "type-fest": "^4.26.1", "xo": "^0.58.0" + }, + "ava": { + "files": [ + "test.js", + "native.test.js" + ] } } diff --git a/test.js b/test.js index f060969..33e2d1e 100644 --- a/test.js +++ b/test.js @@ -41,13 +41,14 @@ test('returns available video codecs', t => { }); test('records screen', async t => { - await recorder.startRecording(); + const monitors = await screens(); + await recorder.startRecordingScreen({screenId: monitors[0].id}); t.true(fs.existsSync(await recorder.isFileReady)); await delay(1000); const videoPath = await recorder.stopRecording(); t.true(fs.existsSync(videoPath)); const buffer = await readChunk(videoPath, {length: 4100}); const fileType = await fileTypeFromBuffer(buffer); - t.is(fileType.ext, 'mov'); + t.is(fileType.ext, 'mp4'); fs.unlinkSync(videoPath); }); From 90d3b0f954caa47c590ffadda4656b4f46a90619 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Mon, 11 Nov 2024 17:49:29 -0500 Subject: [PATCH 02/27] Update macos image --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3ea37ec..dd04da6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,7 +5,7 @@ on: jobs: test: name: Node.js ${{ matrix.node-version }} - runs-on: macos-14 + runs-on: macos-15 strategy: fail-fast: false matrix: From 125c0a897af1dec4cc423f0e49bd2e5c7685b6ec Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sat, 16 Nov 2024 14:21:23 -0500 Subject: [PATCH 03/27] Attempt to build both targets --- .github/workflows/release.yml | 42 +++++++++++++++++++++ Package.resolved | 2 +- Sources/ApertureCLI/record.swift | 6 ++- Sources/ApertureModule/ApertureModule.swift | 8 ++-- package.json | 8 ++-- 5 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c6e3d1a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Build + +on: + push: + pull_request: + +jobs: + build-arm: + runs-on: macos-15 + steps: + - uses: actions/checkout@v3 + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Install dependencies + run: npm ci + - name: Build arm artifact + run: npm run build:module:arm + - name: Upload arm artifact + uses: actions/upload-artifact@v3 + with: + name: arm-artifact + path: aperture-arm.node + + build-x86: + runs-on: macos-15-large + steps: + - uses: actions/checkout@v3 + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + - name: Install dependencies + run: npm ci + - name: Build x86 artifact + run: npm run build:module:x86 + - name: Upload arm artifact + uses: actions/upload-artifact@v3 + with: + name: x86-artifact + path: aperture-x86.node diff --git a/Package.resolved b/Package.resolved index 44560f8..462e1da 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/wulkano/Aperture", "state" : { "branch" : "george/rewrite-in-screen-capture-kit", - "revision" : "6a3adffa8b3af3fd766e581bddf2c4416bf4547a" + "revision" : "eaaee3b550c23b86e0df39ae3261d0024923ecfd" } }, { diff --git a/Sources/ApertureCLI/record.swift b/Sources/ApertureCLI/record.swift index 7c509d0..86d5e49 100644 --- a/Sources/ApertureCLI/record.swift +++ b/Sources/ApertureCLI/record.swift @@ -57,13 +57,15 @@ func record(_ optionsString: String, processId: String, targetType: TargetType) observers.append( ApertureEvents.answerEvent(processId: processId, event: InEvent.pause.rawValue) { _ in - recorder.pause() + try? recorder.pause() } ) observers.append( ApertureEvents.answerEvent(processId: processId, event: InEvent.resume.rawValue) { _ in - recorder.resume() + Task { + try? await recorder.resume() + } } ) diff --git a/Sources/ApertureModule/ApertureModule.swift b/Sources/ApertureModule/ApertureModule.swift index 22512f3..546910b 100644 --- a/Sources/ApertureModule/ApertureModule.swift +++ b/Sources/ApertureModule/ApertureModule.swift @@ -47,14 +47,14 @@ import AVFoundation @NodeActor @NodeMethod - func pause() { - self.recorder.pause() + func pause() throws { + try self.recorder.pause() } @NodeActor @NodeMethod - func resume() { - self.recorder.resume() + func resume() async throws { + try await self.recorder.resume() } @NodeActor diff --git a/package.json b/package.json index 19b301a..96743d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aperture", - "version": "7.0.0", + "version": "8.0.0", "description": "Record the screen on macOS", "license": "MIT", "repository": "wulkano/aperture-node", @@ -25,9 +25,11 @@ "build:cli": "npm run build:cli:build && npm run build:cli:move", "build:cli:build": "swift build --configuration=release --product aperture --arch arm64 --arch x86_64", "build:cli:move": "mv .build/apple/Products/Release/aperture .", - "build:module": "npm run build:module:build && npm run build:module:move && npm run build:module:sign", + "build:module:arm": "npm run build:module:build && npm run build:module:move:arm && npm run build:module:sign", + "build:module:x86": "npm run build:module:build && npm run build:module:move:x86 && npm run build:module:sign", "build:module:build": "swift build -c release --product aperture-module -Xlinker -undefined -Xlinker dynamic_lookup", - "build:module:move": "mv .build/arm64-apple-macosx/release/libaperture-module.dylib ./aperture.node", + "build:module:move:arm": "mv .build/arm64-apple-macosx/release/libaperture-module.dylib ./aperture-arm.node", + "build:module:move:x86": "ls .build && mv .build/x86_64-apple-macosx/release/libaperture-module.dylib ./aperture-x86.node", "build:module:sign": "codesign -fs - ./aperture.node", "prepublish": "npm run build" }, From bd2c74be1e25cf23a6954dfc4c21639ffb89ce0e Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sat, 16 Nov 2024 14:27:20 -0500 Subject: [PATCH 04/27] Fix workflows --- .github/workflows/release.yml | 5 ++--- package.json | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6e3d1a..f2ce6c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,6 @@ name: Build on: push: - pull_request: jobs: build-arm: @@ -14,7 +13,7 @@ jobs: with: node-version: '18' - name: Install dependencies - run: npm ci + run: npm install - name: Build arm artifact run: npm run build:module:arm - name: Upload arm artifact @@ -32,7 +31,7 @@ jobs: with: node-version: '18' - name: Install dependencies - run: npm ci + run: npm install - name: Build x86 artifact run: npm run build:module:x86 - name: Upload arm artifact diff --git a/package.json b/package.json index 96743d6..8fb8874 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "scripts": { "test": "xo && ava && tsd", - "build": "npm run build:cli && npm run build:module", + "build": "npm run build:cli", "build:cli": "npm run build:cli:build && npm run build:cli:move", "build:cli:build": "swift build --configuration=release --product aperture --arch arm64 --arch x86_64", "build:cli:move": "mv .build/apple/Products/Release/aperture .", From 01598e7d765742078d425bd8bc10f0457a0b07e1 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 11:53:05 -0500 Subject: [PATCH 05/27] Use only native bindings --- .github/workflows/release.yml | 41 --- .gitignore | 3 +- Package.resolved | 11 +- Package.swift | 22 +- Sources/ApertureCLI/ApertureCLI.swift | 261 ----------------- Sources/ApertureCLI/Utilities.swift | 273 ------------------ Sources/ApertureCLI/notifications.swift | 124 -------- Sources/ApertureCLI/record.swift | 115 -------- .../ApertureNode.swift} | 2 - index.js | 201 ++++--------- native.js | 146 ---------- native.test.js | 54 ---- package.json | 26 +- common.js => utils.js | 0 14 files changed, 63 insertions(+), 1216 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 Sources/ApertureCLI/ApertureCLI.swift delete mode 100644 Sources/ApertureCLI/Utilities.swift delete mode 100644 Sources/ApertureCLI/notifications.swift delete mode 100644 Sources/ApertureCLI/record.swift rename Sources/{ApertureModule/ApertureModule.swift => ApertureNode/ApertureNode.swift} (99%) delete mode 100644 native.js delete mode 100644 native.test.js rename common.js => utils.js (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index f2ce6c5..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Build - -on: - push: - -jobs: - build-arm: - runs-on: macos-15 - steps: - - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - name: Install dependencies - run: npm install - - name: Build arm artifact - run: npm run build:module:arm - - name: Upload arm artifact - uses: actions/upload-artifact@v3 - with: - name: arm-artifact - path: aperture-arm.node - - build-x86: - runs-on: macos-15-large - steps: - - uses: actions/checkout@v3 - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - name: Install dependencies - run: npm install - - name: Build x86 artifact - run: npm run build:module:x86 - - name: Upload arm artifact - uses: actions/upload-artifact@v3 - with: - name: x86-artifact - path: aperture-x86.node diff --git a/.gitignore b/.gitignore index da4a8b2..81bcbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,7 @@ yarn.lock xcuserdata /Packages /*.xcodeproj -/aperture -/aperture.node +/build recording.mp4 diff --git a/Package.resolved b/Package.resolved index 462e1da..4bbef0a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,16 +6,7 @@ "location" : "https://github.com/wulkano/Aperture", "state" : { "branch" : "george/rewrite-in-screen-capture-kit", - "revision" : "eaaee3b550c23b86e0df39ae3261d0024923ecfd" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "41982a3656a71c768319979febd796c6fd111d5c", - "version" : "1.5.0" + "revision" : "ac1febd90238b0bbd1989beac2d7db3a4ca20f41" } }, { diff --git a/Package.swift b/Package.swift index 453b8a5..319ec17 100644 --- a/Package.swift +++ b/Package.swift @@ -2,38 +2,24 @@ import PackageDescription let package = Package( - name: "ApertureCLI", + name: "aperture", platforms: [ .macOS(.v13) ], products: [ - .executable( - name: "aperture", - targets: [ - "ApertureCLI" - ] - ), .library( - name: "aperture-module", + name: "aperture", type: .dynamic, - targets: ["ApertureModule"] + targets: ["ApertureNode"] ) ], dependencies: [ .package(url: "https://github.com/wulkano/Aperture", branch: "george/rewrite-in-screen-capture-kit"), - .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"), .package(path: "node_modules/node-swift") ], targets: [ - .executableTarget( - name: "ApertureCLI", - dependencies: [ - "Aperture", - .product(name: "ArgumentParser", package: "swift-argument-parser") - ] - ), .target( - name: "ApertureModule", + name: "ApertureNode", dependencies: [ "Aperture", .product(name: "NodeAPI", package: "node-swift"), diff --git a/Sources/ApertureCLI/ApertureCLI.swift b/Sources/ApertureCLI/ApertureCLI.swift deleted file mode 100644 index 42518a2..0000000 --- a/Sources/ApertureCLI/ApertureCLI.swift +++ /dev/null @@ -1,261 +0,0 @@ -import Foundation -import Aperture -import ArgumentParser - -enum OutEvent: String, CaseIterable, ExpressibleByArgument { - case onStart - case onFileReady - case onPause - case onResume - case onFinish -} - -enum TargetType: String, CaseIterable, ExpressibleByArgument { - case screen - case window - case audio - case externalDevice -} - -enum InEvent: String, CaseIterable, ExpressibleByArgument { - case pause - case resume - case isPaused - case onPause -} - -extension CaseIterable { - static var asCommaSeparatedList: String { - allCases.map { "\($0)" }.joined(separator: ", ") - } -} - -@main -struct ApertureCLI: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "aperture", - subcommands: [ - List.self, - Record.self, - Events.self - ] - ) -} - -extension ApertureCLI { - struct List: ParsableCommand { - static let configuration = CommandConfiguration( - subcommands: [ - Screens.self, - AudioDevices.self, - Windows.self, - ExternalDevices.self - ] - ) - } - - struct Record: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Start a recording with the given options.") - - @Option(name: .shortAndLong, help: "The ID to use for this process") - var processId = "main" - - @Option(name: .shortAndLong, help: "The type of target to record") - var targetType = TargetType.screen - - @Argument(help: "Stringified JSON object with options passed to Aperture") - var options: String - - mutating func run() throws { - Task { [self] in - do { - try await record(options, processId: processId, targetType: targetType) - } catch { - print(error, to: .standardError) - Darwin.exit(1) - } - } - - RunLoop.main.run() - } - } - - struct Events: ParsableCommand { - static let configuration = CommandConfiguration( - subcommands: [ - Send.self, - Listen.self, - ListenAll.self - ] - ) - } -} - -extension ApertureCLI.List { - struct Screens: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List available screens.") - - mutating func run() throws { - Task { - // Uses stderr because of unrelated stuff being outputted on stdout. - print( - try toJson( - await Aperture.Devices.screen().map { - [ - "id": $0.id, - "name": $0.name, - "width": $0.width, - "height": $0.height, - "frame": $0.frame.asDictionary - ] - } - ), - to: .standardError - ) - Darwin.exit(0) - } - - RunLoop.main.run() - } - } - - struct Windows: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List available windows.") - - @Flag(inversion: .prefixedNo, help: "Exclude desktop windows") - var excludeDesktopWindows = true - - @Flag(inversion: .prefixedNo, help: "Only include windows that are on screen") - var onScreenOnly = true - - mutating func run() throws { - Task { [self] in - // Uses stderr because of unrelated stuff being outputted on stdout. - print( - try toJson( - await Aperture.Devices.window( - excludeDesktopWindows: excludeDesktopWindows, - onScreenWindowsOnly: onScreenOnly - ) - .map { - [ - "id": $0.id, - "title": $0.title as Any, - "applicationName": $0.applicationName as Any, - "applicationBundleIdentifier": $0.applicationBundleIdentifier as Any, - "isActive": $0.isActive, - "isOnScreen": $0.isOnScreen, - "layer": $0.layer, - "frame": $0.frame.asDictionary - ] - } - ), - to: .standardError - ) - - Darwin.exit(0) - } - - RunLoop.main.run() - } - } - - struct AudioDevices: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List available audio devices.") - - mutating func run() throws { - // Uses stderr because of unrelated stuff being outputted on stdout. - print(try toJson(Aperture.Devices.audio().map { ["id": $0.id, "name": $0.name] }), to: .standardError) - } - } - - struct ExternalDevices: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "List available external devices.") - - mutating func run() throws { - // Uses stderr because of unrelated stuff being outputted on stdout. - print(try toJson(Aperture.Devices.iOS().map { ["id": $0.id, "name": $0.name] }), to: .standardError) - } - } -} - -extension ApertureCLI.Events { - struct Send: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Send an event to the given process.") - - @Flag(inversion: .prefixedNo, help: "Wait for event to be received") - var wait = true - - @Option(name: .shortAndLong, help: "The ID of the target process") - var processId = "main" - - @Argument(help: "Name of the event to send. Can be one of:\n\(InEvent.asCommaSeparatedList)") - var event: InEvent - - @Argument(help: "Data to pass to the event") - var data: String? - - mutating func run() { - ApertureEvents.sendEvent(processId: processId, event: event.rawValue, data: data) { notification in - if let data = notification.data { - print(data) - } - - Foundation.exit(0) - } - - if wait { - RunLoop.main.run() - } - } - } - - struct Listen: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Listen to an outcoming event for the given process.") - - @Flag(help: "Exit after receiving the event once") - var exit = false - - @Option(name: .shortAndLong, help: "The ID of the target process") - var processId = "main" - - @Argument(help: "Name of the event to listen for. Can be one of:\n\(OutEvent.asCommaSeparatedList)") - var event: OutEvent - - func run() { - _ = ApertureEvents.answerEvent(processId: processId, event: event.rawValue) { notification in - if let data = notification.data { - print(data) - } - - if exit { - notification.answer() - Foundation.exit(0) - } - } - - RunLoop.main.run() - } - } - - struct ListenAll: ParsableCommand { - static let configuration = CommandConfiguration(abstract: "Listen to all outcoming events for the given process.") - - @Option(name: .shortAndLong, help: "The ID of the target process") - var processId = "main" - - func run() { - for event in OutEvent.allCases { - _ = ApertureEvents.answerEvent(processId: processId, event: event.rawValue) { notification in - if let data = notification.data { - print("\(event) \(data)") - } else { - print(event) - } - } - } - - RunLoop.main.run() - } - } -} diff --git a/Sources/ApertureCLI/Utilities.swift b/Sources/ApertureCLI/Utilities.swift deleted file mode 100644 index 326d527..0000000 --- a/Sources/ApertureCLI/Utilities.swift +++ /dev/null @@ -1,273 +0,0 @@ -import Foundation - -// MARK: - SignalHandler -struct SignalHandler { - typealias CSignalHandler = @convention(c) (Int32) -> Void - typealias SignalHandler = (Signal) -> Void - - private static var handlers = [Signal: [SignalHandler]]() - - private static var cHandler: CSignalHandler = { rawSignal in - let signal = Signal(rawValue: rawSignal) - - guard let signalHandlers = handlers[signal] else { - return - } - - for handler in signalHandlers { - handler(signal) - } - } - - /** - Handle some signals - */ - static func handle(signals: [Signal], handler: @escaping SignalHandler) { - for signal in signals { - // Since Swift has no way of running code on "struct creation", we need to initialize hereā€¦ - if handlers[signal] == nil { - handlers[signal] = [] - } - - handlers[signal]?.append(handler) - - var signalAction = sigaction( - __sigaction_u: unsafeBitCast(cHandler, to: __sigaction_u.self), - sa_mask: 0, - sa_flags: 0 - ) - - _ = withUnsafePointer(to: &signalAction) { pointer in - sigaction(signal.rawValue, pointer, nil) - } - } - } - - /** - Raise a signal. - */ - static func raise(signal: Signal) { - _ = Darwin.raise(signal.rawValue) - } - - /** - Ignore a signal. - */ - static func ignore(signal: Signal) { - _ = Darwin.signal(signal.rawValue, SIG_IGN) - } - - /** - Restore default signal handling. - */ - static func restore(signal: Signal) { - _ = Darwin.signal(signal.rawValue, SIG_DFL) - } -} - -extension SignalHandler { - struct Signal: Hashable { - static let hangup = Signal(rawValue: SIGHUP) - static let interrupt = Signal(rawValue: SIGINT) - static let quit = Signal(rawValue: SIGQUIT) - static let abort = Signal(rawValue: SIGABRT) - static let kill = Signal(rawValue: SIGKILL) - static let alarm = Signal(rawValue: SIGALRM) - static let termination = Signal(rawValue: SIGTERM) - static let userDefined1 = Signal(rawValue: SIGUSR1) - static let userDefined2 = Signal(rawValue: SIGUSR2) - - /** - Signals that cause the process to exit. - */ - static let exitSignals = [ - hangup, - interrupt, - quit, - abort, - alarm, - termination - ] - - let rawValue: Int32 - - init(rawValue: Int32) { - self.rawValue = rawValue - } - } -} - -extension [SignalHandler.Signal] { - static let exitSignals = SignalHandler.Signal.exitSignals -} -// MARK: - - - -// MARK: - CLI utils -extension FileHandle: TextOutputStream { - public func write(_ string: String) { - write(string.data(using: .utf8)!) - } -} - -enum CLI { - static var standardInput = FileHandle.standardInput - static var standardOutput = FileHandle.standardOutput - static var standardError = FileHandle.standardError - - static let arguments = Array(CommandLine.arguments.dropFirst(1)) -} - -extension CLI { - private static let once = Once() - - /** - Called when the process exits, either normally or forced (through signals). - - When this is set, it's up to you to exit the process. - */ - static var onExit: (() -> Void)? { - - didSet { - guard let exitHandler = onExit else { - return - } - - let handler = { - once.run(exitHandler) - } - - atexit_b { - handler() - } - - SignalHandler.handle(signals: .exitSignals) { _ in - handler() - } - } - } - - /** - Called when the process is being forced (through signals) to exit. - - When this is set, it's up to you to exit the process. - */ - static var onForcedExit: ((SignalHandler.Signal) -> Void)? { - didSet { - guard let exitHandler = onForcedExit else { - return - } - - SignalHandler.handle(signals: .exitSignals, handler: exitHandler) - } - } -} - -enum PrintOutputTarget { - case standardOutput - case standardError -} - -/** -Make `print()` accept an array of items. - -Since Swift doesn't support spreading... -*/ -private func print( - _ items: [Any], - separator: String = " ", - terminator: String = "\n", - to output: inout Target -) where Target: TextOutputStream { - let item = items.map { "\($0)" }.joined(separator: separator) - Swift.print(item, terminator: terminator, to: &output) -} - -func print( - _ items: Any..., - separator: String = " ", - terminator: String = "\n", - to output: PrintOutputTarget = .standardOutput -) { - switch output { - case .standardOutput: - print(items, separator: separator, terminator: terminator) - case .standardError: - print(items, separator: separator, terminator: terminator, to: &CLI.standardError) - } -} -// MARK: - - - -// MARK: - Misc -func synchronized(lock: AnyObject, closure: () throws -> T) rethrows -> T { - objc_sync_enter(lock) - defer { - objc_sync_exit(lock) - } - - return try closure() -} - -final class Once { - private var hasRun = false - - /** - Executes the given closure only once (thread-safe) - - ``` - final class Foo { - private let once = Once() - - func bar() { - once.run { - print("Called only once") - } - } - } - - let foo = Foo() - foo.bar() - foo.bar() - ``` - */ - func run(_ closure: () -> Void) { - synchronized(lock: self) { - guard !hasRun else { - return - } - - hasRun = true - closure() - } - } -} - -extension Data { - func jsonDecoded() throws -> T { - try JSONDecoder().decode(T.self, from: self) - } -} - -extension String { - func jsonDecoded() throws -> T { - try data(using: .utf8)!.jsonDecoded() - } -} - -func toJson(_ data: T) throws -> String { - let json = try JSONSerialization.data(withJSONObject: data) - return String(data: json, encoding: .utf8)! -} -// MARK: - - -extension CGRect { - var asDictionary: [String: Any] { - [ - "x": Int(origin.x), - "y": Int(origin.y), - "width": Int(size.width), - "height": Int(size.height) - ] - } -} diff --git a/Sources/ApertureCLI/notifications.swift b/Sources/ApertureCLI/notifications.swift deleted file mode 100644 index d26769e..0000000 --- a/Sources/ApertureCLI/notifications.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation - -final class ApertureNotification { - static func notificationName(forEvent event: String, processId: String) -> String { - "aperture.\(processId).\(event)" - } - - private var notification: Notification - var isAnswered = false - - init(_ notification: Notification) { - self.notification = notification - } - - func getField(_ name: String) -> T? { - notification.userInfo?[name] as? T - } - - var data: String? { getField("data") } - - func answer(_ data: Any? = nil) { - isAnswered = true - - guard - let responseIdentifier: String = getField("responseIdentifier") - else { - return - } - - var payload = [AnyHashable: Any]() - - if let payloadData = data { - payload["data"] = "\(payloadData)" - } - - DistributedNotificationCenter.default().postNotificationName( - .init(responseIdentifier), - object: nil, - userInfo: payload, - deliverImmediately: true - ) - } -} - -enum ApertureEvents { - static func answerEvent( - processId: String, - event: String, - using handler: @escaping (ApertureNotification) -> Void - ) -> NSObjectProtocol { - DistributedNotificationCenter.default().addObserver( - forName: .init(ApertureNotification.notificationName(forEvent: event, processId: processId)), - object: nil, - queue: nil - ) { notification in - let apertureNotification = ApertureNotification(notification) - handler(apertureNotification) - - if !apertureNotification.isAnswered { - apertureNotification.answer() - } - } - } - - static func sendEvent( - processId: String, - event: String, - data: Any?, - using callback: @escaping (ApertureNotification) -> Void - ) { - let notificationName = ApertureNotification.notificationName(forEvent: event, processId: processId) - let responseIdentifier = "\(notificationName).response.\(UUID().uuidString)" - - var payload: [AnyHashable: Any] = ["responseIdentifier": responseIdentifier] - - if let payloadData = data { - payload["data"] = "\(payloadData)" - } - - var observer: AnyObject? - observer = DistributedNotificationCenter.default().addObserver( - forName: .init(responseIdentifier), - object: nil, - queue: nil - ) { notification in - DistributedNotificationCenter.default().removeObserver(observer!) - callback(ApertureNotification(notification)) - } - - DistributedNotificationCenter.default().postNotificationName( - .init( - ApertureNotification.notificationName(forEvent: event, processId: processId) - ), - object: nil, - userInfo: payload, - deliverImmediately: true - ) - } - - static func sendEvent( - processId: String, - event: String, - using callback: @escaping (ApertureNotification) -> Void - ) { - sendEvent( - processId: processId, - event: event, - data: nil, - using: callback - ) - } - - static func sendEvent( - processId: String, - event: String, - data: Any? = nil - ) { - sendEvent( - processId: processId, - event: event, - data: data - ) { _ in } - } -} diff --git a/Sources/ApertureCLI/record.swift b/Sources/ApertureCLI/record.swift deleted file mode 100644 index 86d5e49..0000000 --- a/Sources/ApertureCLI/record.swift +++ /dev/null @@ -1,115 +0,0 @@ -import AVFoundation -import Aperture - -struct Options: Decodable { - let destination: URL - let targetId: String? - let framesPerSecond: Int - let cropRect: CGRect? - let showCursor: Bool - let highlightClicks: Bool - let audioDeviceId: String? - let videoCodec: String? - let losslessAudio: Bool - let recordSystemAudio: Bool -} - -func record(_ optionsString: String, processId: String, targetType: TargetType) async throws { - let options: Options = try optionsString.jsonDecoded() - var observers = [Any]() - - let recorder = Aperture.Recorder() - - recorder.onStart = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFileReady.rawValue) - } - - recorder.onPause = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onPause.rawValue) - } - - recorder.onResume = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onResume.rawValue) - } - - recorder.onError = { - print($0, to: .standardError) - exit(1) - } - - recorder.onFinish = { - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onFinish.rawValue) - - for observer in observers { - DistributedNotificationCenter.default().removeObserver(observer) - } - - exit(0) - } - - CLI.onExit = { - Task { - try await recorder.stopRecording() - } - // Do not call `exit()` here as the video is not always done - // saving at this point and will be corrupted randomly - } - - observers.append( - ApertureEvents.answerEvent(processId: processId, event: InEvent.pause.rawValue) { _ in - try? recorder.pause() - } - ) - - observers.append( - ApertureEvents.answerEvent(processId: processId, event: InEvent.resume.rawValue) { _ in - Task { - try? await recorder.resume() - } - } - ) - - observers.append( - ApertureEvents.answerEvent(processId: processId, event: InEvent.isPaused.rawValue) { notification in - notification.answer(recorder.isPaused) - } - ) - - let videoCodec: Aperture.VideoCodec - if let videoCodecString = options.videoCodec { - videoCodec = try .fromRawValue(videoCodecString) - } else { - videoCodec = .h264 - } - - let target: Aperture.Target - - switch targetType { - case .screen: - target = .screen - case .window: - target = .window - case .audio: - target = .audioOnly - case .externalDevice: - target = .externalDevice - } - - try await recorder.startRecording( - target: target, - options: Aperture.RecordingOptions( - destination: options.destination, - targetID: options.targetId, - framesPerSecond: options.framesPerSecond, - cropRect: options.cropRect, - showCursor: options.showCursor, - highlightClicks: options.highlightClicks, - videoCodec: videoCodec, - losslessAudio: options.losslessAudio, - recordSystemAudio: options.recordSystemAudio, - microphoneDeviceID: options.audioDeviceId != nil ? options.audioDeviceId : nil - ) - ) - - ApertureEvents.sendEvent(processId: processId, event: OutEvent.onStart.rawValue) -} diff --git a/Sources/ApertureModule/ApertureModule.swift b/Sources/ApertureNode/ApertureNode.swift similarity index 99% rename from Sources/ApertureModule/ApertureModule.swift rename to Sources/ApertureNode/ApertureNode.swift index 546910b..76fb4aa 100644 --- a/Sources/ApertureModule/ApertureModule.swift +++ b/Sources/ApertureNode/ApertureNode.swift @@ -3,8 +3,6 @@ import Aperture import Foundation import AVFoundation - - @NodeClass final class Recorder { @NodeActor private var recorder: Aperture.Recorder diff --git a/index.js b/index.js index 8b12045..392b942 100644 --- a/index.js +++ b/index.js @@ -1,20 +1,10 @@ -import {debuglog} from 'node:util'; -import path from 'node:path'; -import url from 'node:url'; -import {execa} from 'execa'; +import {createRequire} from 'node:module'; import {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; -import {fixPathForAsarUnpack} from 'electron-util/node'; -import delay from 'delay'; -import {normalizeOptions} from './common.js'; +import {normalizeOptions} from './utils.js'; -export {videoCodecs} from './common.js'; +export {videoCodecs} from './utils.js'; -const log = debuglog('aperture'); -const getRandomId = () => Math.random().toString(36).slice(2, 15); - -const dirname_ = path.dirname(url.fileURLToPath(import.meta.url)); -// Workaround for https://github.com/electron/electron/issues/9459 -const BINARY = path.join(fixPathForAsarUnpack(dirname_), 'aperture'); +const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/aperture.node'); export class Recorder { constructor() { @@ -64,94 +54,50 @@ export class Recorder { }); } - _startRecording(targetType, options) { - this.processId = getRandomId(); - - return new Promise((resolve, reject) => { - if (this.recorder !== undefined) { - reject(new Error('Call `.stopRecording()` first')); - return; - } - - const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); - - this.tmpPath = tmpPath; - - const timeout = setTimeout(() => { - // `.stopRecording()` was called already - if (this.recorder === undefined) { - return; - } - - const error = new Error('Could not start recording within 5 seconds'); - error.code = 'RECORDER_TIMEOUT'; - this.recorder.kill(); - delete this.recorder; - reject(error); - }, 5000); - - (async () => { - try { - await this.waitForEvent('onStart'); - clearTimeout(timeout); - setTimeout(resolve, 1000); - } catch (error) { - reject(error); - } - })(); - - this.isFileReady = (async () => { - await this.waitForEvent('onFileReady'); - return this.tmpPath; - })(); - - this.recorder = execa(BINARY, [ - 'record', - '--process-id', - this.processId, - '--target-type', - targetType, - JSON.stringify(recorderOptions), - ]); - - this.recorder.catch(error => { - clearTimeout(timeout); - delete this.recorder; - reject(error); - }); - - this.recorder.stdout.setEncoding('utf8'); - this.recorder.stdout.on('data', log); + async _startRecording(targetType, options) { + if (this.recorder !== undefined) { + throw new Error('Call `.stopRecording()` first'); + } + + const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); + + this.tmpPath = tmpPath; + this.recorder = new nativeModule.Recorder(); + + this.isFileReady = new Promise(resolve => { + this.recorder.onStart = () => { + resolve(this.tmpPath); + }; }); - } - async waitForEvent(name, parse) { - const {stdout} = await execa(BINARY, [ - 'events', - 'listen', - '--process-id', - this.processId, - '--exit', - name, - ]); - - if (parse) { - return parse(stdout.trim()); + const finalOptions = { + destination: tmpPath, + framesPerSecond: recorderOptions.framesPerSecond, + showCursor: recorderOptions.showCursor, + highlightClicks: recorderOptions.highlightClicks, + losslessAudio: recorderOptions.losslessAudio, + recordSystemAudio: recorderOptions.recordSystemAudio, + }; + + if (recorderOptions.videoCodec) { + finalOptions.videoCodec = recorderOptions.videoCodec; + } + + if (targetType === 'screen' && options.cropArea) { + finalOptions.cropRect = options.cropArea; } - } - async sendEvent(name, parse) { - const {stdout} = await execa(BINARY, [ - 'events', - 'send', - '--process-id', - this.processId, - name, - ]); - - if (parse) { - return parse(stdout.trim()); + if (recorderOptions.targetId) { + finalOptions.targetId = recorderOptions.targetId; } + + if (recorderOptions.audioDeviceId) { + finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; + } + + console.log(finalOptions); + + await this.recorder.startRecording(targetType === 'audio' ? 'audioOnly' : targetType, finalOptions); } throwIfNotStarted() { @@ -162,29 +108,23 @@ export class Recorder { async pause() { this.throwIfNotStarted(); - await this.sendEvent('pause'); + this.recorder.pause(); } async resume() { this.throwIfNotStarted(); - - await this.sendEvent('resume'); - - // It takes about 1s after the promise resolves for the recording to actually start - await delay(1000); + this.recorder.resume(); } async isPaused() { this.throwIfNotStarted(); - - return this.sendEvent('isPaused', value => value === 'true'); + return this.recorder.isPaused(); } async stopRecording() { this.throwIfNotStarted(); + await this.recorder.stopRecording(); - this.recorder.kill(); - await this.recorder; delete this.recorder; delete this.isFileReady; @@ -194,52 +134,13 @@ export class Recorder { export const recorder = new Recorder(); -const removeWarnings = string => string.split('\n').filter(line => !line.includes('] WARNING:')).join('\n'); - -export const screens = async () => { - const {stderr} = await execa(BINARY, ['list', 'screens']); - - try { - return JSON.parse(removeWarnings(stderr)); - } catch (error) { - throw new Error(stderr, {cause: error}); - } -}; +export const screens = async () => nativeModule.getScreens(); export const windows = async ({ excludeDesktopWindows = true, onScreenOnly = true, -} = {}) => { - const {stderr} = await execa(BINARY, [ - 'list', - 'windows', - excludeDesktopWindows ? '--exclude-desktop-windows' : '--no-exclude-desktop-windows', - onScreenOnly ? '--on-screen-only' : '--no-on-screen-only', - ]); - - try { - return JSON.parse(removeWarnings(stderr)); - } catch (error) { - throw new Error(stderr, {cause: error}); - } -}; - -export const audioDevices = async () => { - const {stderr} = await execa(BINARY, ['list', 'audio-devices']); +} = {}) => nativeModule.getWindows(excludeDesktopWindows, onScreenOnly); - try { - return JSON.parse(removeWarnings(stderr)); - } catch (error) { - throw new Error(stderr, {cause: error}); - } -}; - -export const externalDevices = async () => { - const {stderr} = await execa(BINARY, ['list', 'external-devices']); +export const audioDevices = async () => nativeModule.getAudioDevices(); - try { - return JSON.parse(removeWarnings(stderr)); - } catch (error) { - throw new Error(stderr, {cause: error}); - } -}; +export const externalDevices = async () => nativeModule.getIOSDevices(); diff --git a/native.js b/native.js deleted file mode 100644 index f2ae93c..0000000 --- a/native.js +++ /dev/null @@ -1,146 +0,0 @@ -import {createRequire} from 'node:module'; -import {assertMacOSVersionGreaterThanOrEqualTo} from 'macos-version'; -import {normalizeOptions} from './common.js'; - -export {videoCodecs} from './common.js'; - -const nativeModule = createRequire(import.meta.url)('./aperture.node'); - -export class Recorder { - constructor() { - assertMacOSVersionGreaterThanOrEqualTo('13'); - } - - startRecordingScreen({ - screenId, - ...options - }) { - return this._startRecording('screen', { - ...options, - targetId: screenId, - }); - } - - startRecordingWindow({ - windowId, - ...options - }) { - return this._startRecording('window', { - ...options, - targetId: windowId, - }); - } - - startRecordingExternalDevice({ - deviceId, - ...options - }) { - return this._startRecording('externalDevice', { - ...options, - targetId: deviceId, - }); - } - - startRecordingAudio({ - audioDeviceId, - losslessAudio, - systemAudio, - }) { - return this._startRecording('audio', { - audioDeviceId, - losslessAudio, - systemAudio, - extension: 'm4a', - }); - } - - async _startRecording(targetType, options) { - if (this.recorder !== undefined) { - throw new Error('Call `.stopRecording()` first'); - } - - const {tmpPath, recorderOptions} = normalizeOptions(targetType, options); - - this.tmpPath = tmpPath; - this.recorder = new nativeModule.Recorder(); - - this.isFileReady = new Promise(resolve => { - this.recorder.onStart = () => { - resolve(this.tmpPath); - }; - }); - - const finalOptions = { - destination: tmpPath, - framesPerSecond: recorderOptions.framesPerSecond, - showCursor: recorderOptions.showCursor, - highlightClicks: recorderOptions.highlightClicks, - losslessAudio: recorderOptions.losslessAudio, - recordSystemAudio: recorderOptions.recordSystemAudio, - }; - - if (recorderOptions.videoCodec) { - finalOptions.videoCodec = recorderOptions.videoCodec; - } - - if (targetType === 'screen' && options.cropArea) { - finalOptions.cropRect = options.cropArea; - } - - if (recorderOptions.targetId) { - finalOptions.targetId = recorderOptions.targetId; - } - - if (recorderOptions.audioDeviceId) { - finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; - } - - console.log(finalOptions); - - await this.recorder.startRecording(targetType === 'audio' ? 'audioOnly' : targetType, finalOptions); - } - - throwIfNotStarted() { - if (this.recorder === undefined) { - throw new Error('Call `.startRecording()` first'); - } - } - - async pause() { - this.throwIfNotStarted(); - this.recorder.pause(); - } - - async resume() { - this.throwIfNotStarted(); - this.recorder.resume(); - } - - async isPaused() { - this.throwIfNotStarted(); - return this.recorder.isPaused(); - } - - async stopRecording() { - this.throwIfNotStarted(); - await this.recorder.stopRecording(); - - delete this.recorder; - delete this.isFileReady; - - return this.tmpPath; - } -} - -export const recorder = new Recorder(); - -export const screens = async () => nativeModule.getScreens(); - -export const windows = async ({ - excludeDesktopWindows = true, - onScreenOnly = true, -} = {}) => nativeModule.getWindows(excludeDesktopWindows, onScreenOnly); - -export const audioDevices = async () => nativeModule.getAudioDevices(); - -export const externalDevices = async () => nativeModule.getIOSDevices(); diff --git a/native.test.js b/native.test.js deleted file mode 100644 index fbdf218..0000000 --- a/native.test.js +++ /dev/null @@ -1,54 +0,0 @@ -import fs from 'node:fs'; -import test from 'ava'; -import delay from 'delay'; -import {fileTypeFromBuffer} from 'file-type'; -import {readChunk} from 'read-chunk'; -import { - recorder, - audioDevices, - screens, - videoCodecs, -} from './native.js'; - -test('returns audio devices', async t => { - const devices = await audioDevices(); - console.log('Audio devices:', devices); - - t.true(Array.isArray(devices)); - - if (devices.length > 0) { - t.true(devices[0].id.length > 0); - t.true(devices[0].name.length > 0); - } -}); - -test('returns screens', async t => { - const monitors = await screens(); - console.log('Screens:', monitors); - - t.true(Array.isArray(monitors)); - - if (monitors.length > 0) { - t.true(monitors[0].id > 0); - t.true(monitors[0].name.length > 0); - } -}); - -test('returns available video codecs', t => { - const codecs = videoCodecs; - console.log('Video codecs:', codecs); - t.true(codecs.has('h264')); -}); - -test('records screen', async t => { - const monitors = await screens(); - await recorder.startRecordingScreen({screenId: monitors[0].id}); - t.true(fs.existsSync(await recorder.isFileReady)); - await delay(1000); - const videoPath = await recorder.stopRecording(); - t.true(fs.existsSync(videoPath)); - const buffer = await readChunk(videoPath, {length: 4100}); - const fileType = await fileTypeFromBuffer(buffer); - t.is(fileType.ext, 'mp4'); - fs.unlinkSync(videoPath); -}); diff --git a/package.json b/package.json index 8fb8874..cc2592c 100644 --- a/package.json +++ b/package.json @@ -19,27 +19,19 @@ "engines": { "node": ">=18" }, + "swift": { "builder": "xcode" }, "scripts": { "test": "xo && ava && tsd", - "build": "npm run build:cli", - "build:cli": "npm run build:cli:build && npm run build:cli:move", - "build:cli:build": "swift build --configuration=release --product aperture --arch arm64 --arch x86_64", - "build:cli:move": "mv .build/apple/Products/Release/aperture .", - "build:module:arm": "npm run build:module:build && npm run build:module:move:arm && npm run build:module:sign", - "build:module:x86": "npm run build:module:build && npm run build:module:move:x86 && npm run build:module:sign", - "build:module:build": "swift build -c release --product aperture-module -Xlinker -undefined -Xlinker dynamic_lookup", - "build:module:move:arm": "mv .build/arm64-apple-macosx/release/libaperture-module.dylib ./aperture-arm.node", - "build:module:move:x86": "ls .build && mv .build/x86_64-apple-macosx/release/libaperture-module.dylib ./aperture-x86.node", - "build:module:sign": "codesign -fs - ./aperture.node", + "build": "npm run build:build && npm run build:move", + "build:build": "node-swift build", + "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.framework ./build/aperture.framework && mv ./.build/release/NodeAPI.framework ./build/NodeAPI.framework", "prepublish": "npm run build" }, "files": [ "index.js", "index.d.ts", - "common.js", - "native.js", - "aperture", - "aperture.node" + "utils.js", + "build" ], "dependencies": { "delay": "^6.0.0", @@ -57,11 +49,5 @@ "tsd": "^0.30.7", "type-fest": "^4.26.1", "xo": "^0.58.0" - }, - "ava": { - "files": [ - "test.js", - "native.test.js" - ] } } diff --git a/common.js b/utils.js similarity index 100% rename from common.js rename to utils.js From 1e569b8e627ea9ea0a0e6f560bd7438d01ce04b9 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 12:56:56 -0500 Subject: [PATCH 06/27] Fix import path --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 392b942..fee77e5 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,7 @@ import {normalizeOptions} from './utils.js'; export {videoCodecs} from './utils.js'; -const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/aperture.node'); +const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/Versions/A/aperture.node'); export class Recorder { constructor() { From 3f3d9dfc120e12c8af4ef4701e49e69997b0c511 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 14:31:41 -0500 Subject: [PATCH 07/27] Attempt to run tests on Intel mac --- .github/workflows/main.yml | 29 +++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dd04da6..3aee7e5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,4 +18,33 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install + - run: npm build + - run: npm test + - name: Upload build + uses: actions/upload-artifact@v3 + with: + name: binding-artifact-${{ matrix.node-version }} + path: build + + test-intel: + needs: [test] + name: Node.js ${{ matrix.node-version }} (Intel) + runs-on: macos-13 + strategy: + fail-fast: false + matrix: + node-version: + - 20 + - 18 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - name: Download build + uses: actions/download-artifact@v3 + with: + name: binding-artifact-${{ matrix.node-version }} + path: build - run: npm test diff --git a/package.json b/package.json index cc2592c..7d65ce8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build": "npm run build:build && npm run build:move", "build:build": "node-swift build", "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.framework ./build/aperture.framework && mv ./.build/release/NodeAPI.framework ./build/NodeAPI.framework", - "prepublish": "npm run build" + "prepack": "npm run build" }, "files": [ "index.js", From c1544277e691ddc24402898ae08e07f1fc7c5c38 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 14:33:04 -0500 Subject: [PATCH 08/27] Fix build command --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3aee7e5..3453f22 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - - run: npm build + - run: npm run build - run: npm test - name: Upload build uses: actions/upload-artifact@v3 From 1762f32548710d8cb1ee95be055116c624054085 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 14:49:08 -0500 Subject: [PATCH 09/27] Print os info in test --- test.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test.js b/test.js index 33e2d1e..276c4df 100644 --- a/test.js +++ b/test.js @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import test from 'ava'; import delay from 'delay'; import {fileTypeFromBuffer} from 'file-type'; @@ -10,6 +11,8 @@ import { videoCodecs, } from './index.js'; +console.log(`Running on macOS ${os.arch()} ${os.version()}`); + test('returns audio devices', async t => { const devices = await audioDevices(); console.log('Audio devices:', devices); From d853a48a592a4637711681eb0d56b20eb6d70583 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 15:16:28 -0500 Subject: [PATCH 10/27] Debug permissions --- .github/workflows/main.yml | 63 +++++++++++++++++++------------------- test.js | 2 +- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3453f22..c46733c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,33 +1,33 @@ name: CI on: - push - - pull_request + # - pull_request jobs: - test: - name: Node.js ${{ matrix.node-version }} - runs-on: macos-15 - strategy: - fail-fast: false - matrix: - node-version: - - 20 - - 18 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run build - - run: npm test - - name: Upload build - uses: actions/upload-artifact@v3 - with: - name: binding-artifact-${{ matrix.node-version }} - path: build + # test: + # name: Node.js ${{ matrix.node-version }} + # runs-on: macos-15 + # strategy: + # fail-fast: false + # matrix: + # node-version: + # - 20 + # - 18 + # steps: + # - uses: actions/checkout@v4 + # - uses: actions/setup-node@v4 + # with: + # node-version: ${{ matrix.node-version }} + # - run: npm install + # - run: npm run build + # - run: npm test + # - name: Upload build + # uses: actions/upload-artifact@v3 + # with: + # name: binding-artifact-${{ matrix.node-version }} + # path: build test-intel: - needs: [test] + # needs: [test] name: Node.js ${{ matrix.node-version }} (Intel) runs-on: macos-13 strategy: @@ -41,10 +41,11 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - run: npm install - - name: Download build - uses: actions/download-artifact@v3 - with: - name: binding-artifact-${{ matrix.node-version }} - path: build - - run: npm test + - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceAccessibility + # - run: npm install + # - name: Download build + # uses: actions/download-artifact@v3 + # with: + # name: binding-artifact-${{ matrix.node-version }} + # path: build + # - run: npm test diff --git a/test.js b/test.js index 276c4df..642b3e6 100644 --- a/test.js +++ b/test.js @@ -11,7 +11,7 @@ import { videoCodecs, } from './index.js'; -console.log(`Running on macOS ${os.arch()} ${os.version()}`); +console.log(`Running on macOS ${os.arch()} ${os.version()}\n`); test('returns audio devices', async t => { const devices = await audioDevices(); From e41c3fa55f06e20aedc843368529c6c777d40cd1 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 15:17:26 -0500 Subject: [PATCH 11/27] Debug permissions --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c46733c..bab0a36 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,7 +41,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceAccessibility + - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceScreenCapture + - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" # - run: npm install # - name: Download build # uses: actions/download-artifact@v3 From 1a296ba163bd674c6ccd3b1f6eecb10cf7de9067 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 15:42:07 -0500 Subject: [PATCH 12/27] Find process --- test.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test.js b/test.js index 642b3e6..a24d899 100644 --- a/test.js +++ b/test.js @@ -1,5 +1,6 @@ import fs from 'node:fs'; import os from 'node:os'; +import { execSync } from 'node:child_process'; import test from 'ava'; import delay from 'delay'; import {fileTypeFromBuffer} from 'file-type'; @@ -13,6 +14,30 @@ import { console.log(`Running on macOS ${os.arch()} ${os.version()}\n`); +(() => { + let pid = process.pid; + + const getParentPid = (pid) => { + return execSync(`ps -p ${pid} -o ppid=`).toString().trim(); + } + + const getCommand = (pid) => { + return execSync(`ps -p ${pid} -o command=`).toString().trim(); + } + + while(true) { + const parent = getParentPid(pid); + + if (parent === '0' || parent === '1') { + const command = getCommand(pid); + console.log(`Command: ${command}\n\n`); + break; + } + + pid = parent; + } +})(); + test('returns audio devices', async t => { const devices = await audioDevices(); console.log('Audio devices:', devices); From e6613a4628f187a0996ea3123279779f7a7e1e31 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 15:43:52 -0500 Subject: [PATCH 13/27] Find process --- .github/workflows/main.yml | 1 + temp.js | 25 +++++++++++++++++++++++++ test.js | 25 ------------------------- 3 files changed, 26 insertions(+), 25 deletions(-) create mode 100644 temp.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bab0a36..b553f6f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -43,6 +43,7 @@ jobs: node-version: ${{ matrix.node-version }} - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceScreenCapture - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" + - run: node temp.js # - run: npm install # - name: Download build # uses: actions/download-artifact@v3 diff --git a/temp.js b/temp.js new file mode 100644 index 0000000..c90e911 --- /dev/null +++ b/temp.js @@ -0,0 +1,25 @@ +import { execSync } from 'node:child_process'; + +let pid = process.pid; + +const getParentPid = (pid) => { + return execSync(`ps -p ${pid} -o ppid=`).toString().trim(); +} + +const getCommand = (pid) => { + return execSync(`ps -p ${pid} -o command=`).toString().trim(); +} + +while(true) { + const parent = getParentPid(pid); + + if (parent === '0' || parent === '1') { + const command = getCommand(pid); + console.log(`\nCommand: ${command}\n\n`); + break; + } + + console.log(`PID: ${pid}, Parent PID: ${parent}, Command: ${getCommand(pid)}\n`); + + pid = parent; +} \ No newline at end of file diff --git a/test.js b/test.js index a24d899..642b3e6 100644 --- a/test.js +++ b/test.js @@ -1,6 +1,5 @@ import fs from 'node:fs'; import os from 'node:os'; -import { execSync } from 'node:child_process'; import test from 'ava'; import delay from 'delay'; import {fileTypeFromBuffer} from 'file-type'; @@ -14,30 +13,6 @@ import { console.log(`Running on macOS ${os.arch()} ${os.version()}\n`); -(() => { - let pid = process.pid; - - const getParentPid = (pid) => { - return execSync(`ps -p ${pid} -o ppid=`).toString().trim(); - } - - const getCommand = (pid) => { - return execSync(`ps -p ${pid} -o command=`).toString().trim(); - } - - while(true) { - const parent = getParentPid(pid); - - if (parent === '0' || parent === '1') { - const command = getCommand(pid); - console.log(`Command: ${command}\n\n`); - break; - } - - pid = parent; - } -})(); - test('returns audio devices', async t => { const devices = await audioDevices(); console.log('Audio devices:', devices); From c0ce47e051dc66fcdfd2768fd9f2221f9acb740c Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 15:46:16 -0500 Subject: [PATCH 14/27] Re-enable tests --- .github/workflows/main.yml | 60 +++++++++++++++++++------------------- test.js | 2 ++ 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b553f6f..76de0f1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,28 +3,28 @@ on: - push # - pull_request jobs: - # test: - # name: Node.js ${{ matrix.node-version }} - # runs-on: macos-15 - # strategy: - # fail-fast: false - # matrix: - # node-version: - # - 20 - # - 18 - # steps: - # - uses: actions/checkout@v4 - # - uses: actions/setup-node@v4 - # with: - # node-version: ${{ matrix.node-version }} - # - run: npm install - # - run: npm run build - # - run: npm test - # - name: Upload build - # uses: actions/upload-artifact@v3 - # with: - # name: binding-artifact-${{ matrix.node-version }} - # path: build + test: + name: Node.js ${{ matrix.node-version }} + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + node-version: + - 20 + # - 18 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm run build + - run: npm test + - name: Upload build + uses: actions/upload-artifact@v3 + with: + name: binding-artifact-${{ matrix.node-version }} + path: build test-intel: # needs: [test] @@ -35,7 +35,7 @@ jobs: matrix: node-version: - 20 - - 18 + # - 18 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -44,10 +44,10 @@ jobs: - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceScreenCapture - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" - run: node temp.js - # - run: npm install - # - name: Download build - # uses: actions/download-artifact@v3 - # with: - # name: binding-artifact-${{ matrix.node-version }} - # path: build - # - run: npm test + - run: npm install + - name: Download build + uses: actions/download-artifact@v3 + with: + name: binding-artifact-${{ matrix.node-version }} + path: build + - run: npm test diff --git a/test.js b/test.js index 642b3e6..f16b744 100644 --- a/test.js +++ b/test.js @@ -11,6 +11,8 @@ import { videoCodecs, } from './index.js'; +import './temp.js'; + console.log(`Running on macOS ${os.arch()} ${os.version()}\n`); test('returns audio devices', async t => { From 3bb9970923685fcf5eccfdb1accd171f876cbd8a Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 15:49:24 -0500 Subject: [PATCH 15/27] Fix workflow --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 76de0f1..41c88b1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,7 +27,7 @@ jobs: path: build test-intel: - # needs: [test] + needs: [test] name: Node.js ${{ matrix.node-version }} (Intel) runs-on: macos-13 strategy: From 0154f65ad8fe77108a0a66a48b0c9fc8c4cb873a Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 16:07:01 -0500 Subject: [PATCH 16/27] Remove xo temporarily --- .github/workflows/main.yml | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 41c88b1..a55169e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,6 +17,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceScreenCapture + - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" - run: npm install - run: npm run build - run: npm test diff --git a/package.json b/package.json index 7d65ce8..cae481c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "swift": { "builder": "xcode" }, "scripts": { - "test": "xo && ava && tsd", + "test": "ava && tsd", "build": "npm run build:build && npm run build:move", "build:build": "node-swift build", "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.framework ./build/aperture.framework && mv ./.build/release/NodeAPI.framework ./build/NodeAPI.framework", From 0f231fb199ff186bbcd48630527165eece80053f Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 20:35:25 -0500 Subject: [PATCH 17/27] Try to run tests on Intel --- .github/workflows/main.yml | 5 ----- index.js | 5 ++--- package.json | 3 +-- temp.js | 25 ------------------------- test.js | 13 ++++++++----- 5 files changed, 11 insertions(+), 40 deletions(-) delete mode 100644 temp.js diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a55169e..1808fa4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,8 +17,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceScreenCapture - - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" - run: npm install - run: npm run build - run: npm test @@ -43,9 +41,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" | grep kTCCServiceScreenCapture - - run: sudo sqlite3 /Library/Application\ Support/com.apple.TCC/TCC.db "select * from access;" - - run: node temp.js - run: npm install - name: Download build uses: actions/download-artifact@v3 diff --git a/index.js b/index.js index fee77e5..a736556 100644 --- a/index.js +++ b/index.js @@ -4,7 +4,8 @@ import {normalizeOptions} from './utils.js'; export {videoCodecs} from './utils.js'; -const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/Versions/A/aperture.node'); +// const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/Versions/A/aperture.node'); +const nativeModule = createRequire(import.meta.url)('./build/aperture.node'); export class Recorder { constructor() { @@ -95,8 +96,6 @@ export class Recorder { finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; } - console.log(finalOptions); - await this.recorder.startRecording(targetType === 'audio' ? 'audioOnly' : targetType, finalOptions); } diff --git a/package.json b/package.json index cae481c..1e52766 100644 --- a/package.json +++ b/package.json @@ -19,12 +19,11 @@ "engines": { "node": ">=18" }, - "swift": { "builder": "xcode" }, "scripts": { "test": "ava && tsd", "build": "npm run build:build && npm run build:move", "build:build": "node-swift build", - "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.framework ./build/aperture.framework && mv ./.build/release/NodeAPI.framework ./build/NodeAPI.framework", + "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.node ./build/aperture.node && mv ./.build/release/libNodeAPI.dylib ./build/libNodeAPI.dylib", "prepack": "npm run build" }, "files": [ diff --git a/temp.js b/temp.js deleted file mode 100644 index c90e911..0000000 --- a/temp.js +++ /dev/null @@ -1,25 +0,0 @@ -import { execSync } from 'node:child_process'; - -let pid = process.pid; - -const getParentPid = (pid) => { - return execSync(`ps -p ${pid} -o ppid=`).toString().trim(); -} - -const getCommand = (pid) => { - return execSync(`ps -p ${pid} -o command=`).toString().trim(); -} - -while(true) { - const parent = getParentPid(pid); - - if (parent === '0' || parent === '1') { - const command = getCommand(pid); - console.log(`\nCommand: ${command}\n\n`); - break; - } - - console.log(`PID: ${pid}, Parent PID: ${parent}, Command: ${getCommand(pid)}\n`); - - pid = parent; -} \ No newline at end of file diff --git a/test.js b/test.js index f16b744..5f73107 100644 --- a/test.js +++ b/test.js @@ -11,13 +11,10 @@ import { videoCodecs, } from './index.js'; -import './temp.js'; - console.log(`Running on macOS ${os.arch()} ${os.version()}\n`); test('returns audio devices', async t => { const devices = await audioDevices(); - console.log('Audio devices:', devices); t.true(Array.isArray(devices)); @@ -29,7 +26,6 @@ test('returns audio devices', async t => { test('returns screens', async t => { const monitors = await screens(); - console.log('Screens:', monitors); t.true(Array.isArray(monitors)); @@ -41,11 +37,18 @@ test('returns screens', async t => { test('returns available video codecs', t => { const codecs = videoCodecs; - console.log('Video codecs:', codecs); t.true(codecs.has('h264')); }); test('records screen', async t => { + if (os.arch() === 'x64') { + // The GH runner for x64 does not have screen capture permissions, so this fails + // The main purpose of the x64 runner is to make sure the binding if built correctly for cross-platform, + // so we are ok to skip this test + t.pass(); + return; + } + const monitors = await screens(); await recorder.startRecordingScreen({screenId: monitors[0].id}); t.true(fs.existsSync(await recorder.isFileReady)); From aa19366a22d3181e06d7bc9ebb2a2bffb69a90f6 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Sun, 17 Nov 2024 21:13:34 -0500 Subject: [PATCH 18/27] Bring back cross-platform build --- index.js | 3 +-- package.json | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index a736556..3527c69 100644 --- a/index.js +++ b/index.js @@ -4,8 +4,7 @@ import {normalizeOptions} from './utils.js'; export {videoCodecs} from './utils.js'; -// const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/Versions/A/aperture.node'); -const nativeModule = createRequire(import.meta.url)('./build/aperture.node'); +const nativeModule = createRequire(import.meta.url)('./build/aperture.framework/Versions/A/aperture.node'); export class Recorder { constructor() { diff --git a/package.json b/package.json index 1e52766..7d65ce8 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,12 @@ "engines": { "node": ">=18" }, + "swift": { "builder": "xcode" }, "scripts": { - "test": "ava && tsd", + "test": "xo && ava && tsd", "build": "npm run build:build && npm run build:move", "build:build": "node-swift build", - "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.node ./build/aperture.node && mv ./.build/release/libNodeAPI.dylib ./build/libNodeAPI.dylib", + "build:move": "rm -rf build && mkdir build && mv ./.build/release/aperture.framework ./build/aperture.framework && mv ./.build/release/NodeAPI.framework ./build/NodeAPI.framework", "prepack": "npm run build" }, "files": [ From 5a90466fadd593af6716077645f9666a1e940ffb Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Wed, 20 Nov 2024 19:24:15 -0500 Subject: [PATCH 19/27] Update api --- Package.resolved | 2 +- Sources/ApertureNode/ApertureNode.swift | 12 ++++++------ index.d.ts | 4 ++-- index.js | 6 +++--- utils.js | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Package.resolved b/Package.resolved index 4bbef0a..3684df1 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/wulkano/Aperture", "state" : { "branch" : "george/rewrite-in-screen-capture-kit", - "revision" : "ac1febd90238b0bbd1989beac2d7db3a4ca20f41" + "revision" : "d89d3e4b827b1e284388788976be79e3ee3cb798" } }, { diff --git a/Sources/ApertureNode/ApertureNode.swift b/Sources/ApertureNode/ApertureNode.swift index 76fb4aa..c450d02 100644 --- a/Sources/ApertureNode/ApertureNode.swift +++ b/Sources/ApertureNode/ApertureNode.swift @@ -18,7 +18,7 @@ import AVFoundation @NodeActor @NodeMethod - func startRecording(_ targetString: NodeString, _ options: NodeObject) async throws { + func start(_ targetString: NodeString, _ options: NodeObject) async throws { let target: Aperture.Target switch try targetString.string() { @@ -34,13 +34,13 @@ import AVFoundation throw try NodeError(code: nil, message: "Invalid value provided for target. screen, window, audioOnly or externalDevice expected.") } - try await self.recorder.startRecording(target: target, options: options.asOptions()) + try await self.recorder.start(target: target, options: options.asOptions()) } @NodeActor @NodeMethod - func stopRecording() async throws { - try await self.recorder.stopRecording() + func stop() async throws { + try await self.recorder.stop() } @NodeActor @@ -280,8 +280,8 @@ extension Aperture.Devices.Window: @retroactive NodeValueConvertible { "id": String(self.id), "title": self.title, "frame": self.frame.nodeValue(), - "applicationName": self.applicationName, - "applicationBundleIdentifier": self.applicationBundleIdentifier, + "appName": self.appName, + "appBundleIdentifier": self.appBundleIdentifier, "isActive": self.isActive, "isOnScreen": self.isOnScreen, "layer": self.layer diff --git a/index.d.ts b/index.d.ts index c54b93d..79df197 100644 --- a/index.d.ts +++ b/index.d.ts @@ -18,8 +18,8 @@ export type Screen = { export type Window = { id: string; title?: string; - applicationName?: string; - applicationBundleIdentifier?: string; + appName?: string; + appBundleIdentifier?: string; isActive: boolean; isOnScreen: boolean; layer: number; diff --git a/index.js b/index.js index 3527c69..ed8f6b9 100644 --- a/index.js +++ b/index.js @@ -46,7 +46,7 @@ export class Recorder { losslessAudio, systemAudio, }) { - return this._startRecording('audio', { + return this._startRecording('audioOnly', { audioDeviceId, losslessAudio, systemAudio, @@ -95,7 +95,7 @@ export class Recorder { finalOptions.microphoneDeviceID = recorderOptions.audioDeviceId; } - await this.recorder.startRecording(targetType === 'audio' ? 'audioOnly' : targetType, finalOptions); + await this.recorder.start(targetType, finalOptions); } throwIfNotStarted() { @@ -121,7 +121,7 @@ export class Recorder { async stopRecording() { this.throwIfNotStarted(); - await this.recorder.stopRecording(); + await this.recorder.stop(); delete this.recorder; delete this.isFileReady; diff --git a/utils.js b/utils.js index 539e5c8..ddb5576 100644 --- a/utils.js +++ b/utils.js @@ -55,7 +55,7 @@ export function normalizeOptions(targetType, { recordSystemAudio: systemAudio, }; - if (videoCodec && targetType !== 'audio') { + if (videoCodec && targetType !== 'audioOnly') { const codecMap = new Map([ ['h264', ['mp4', 'mov', 'm4v']], ['hevc', ['mp4', 'mov', 'm4v']], @@ -81,7 +81,7 @@ export function normalizeOptions(targetType, { } const temporaryPath = temporaryFile({ - extension: targetType === 'audio' ? 'm4a' : extension, + extension: targetType === 'audioOnly' ? 'm4a' : extension, }); recorderOptions.destination = fileUrl(temporaryPath); From 50a8df8e35a630573ca1853bfd885c19a59b58da Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Wed, 20 Nov 2024 19:40:36 -0500 Subject: [PATCH 20/27] Update readme --- readme.md | 224 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 171 insertions(+), 53 deletions(-) diff --git a/readme.md b/readme.md index 8eebc13..5ddfa20 100644 --- a/readme.md +++ b/readme.md @@ -8,15 +8,18 @@ npm install aperture ``` -*Requires macOS 10.13 or later.* +*Requires macOS 13 or later.* ## Usage ```js import {setTimeout} from 'node:timers/promises'; -import {recorder} from 'aperture'; +import {recorder, screens} from 'aperture'; + +const allScreens = await screens(); const options = { + screenId: allScreens[0].id, fps: 30, cropArea: { x: 100, @@ -26,7 +29,7 @@ const options = { }, }; -await recorder.startRecording(options); +await recorder.startRecordingScreen(options); await setTimeout(3000); @@ -38,7 +41,7 @@ See [`example.js`](example.js) if you want to quickly try it out. _(The example ## API -#### screens() -> `Promise` +#### screens() -> `Promise` Get a list of screens. The first screen is the primary screen. @@ -47,13 +50,62 @@ Example: ```js [ { - id: 69732482, + id: '69732482', name: 'Color LCD', + width: 1280, + height: 800, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } }, ]; ``` -#### audioDevices() -> `Promise` +#### windows(options: WindowOptions) -> `Promise` + +Get a list of windows + +##### WindowOptions.excludeDesktopWindows + +Type: `Boolean`\ +Default: `true` + +Exclude desktop windows like Finder, Dock, and Desktop. + +##### WindowOptions.onScreenOnly + +Type: `Boolean`\ +Default: `true` + +Only include windows that are on screen. + + +Example: + +```js +[ + { + id: '69732482', + title: 'Unicorn', + applicationName: 'Safari', + applicationBundleIdentifier: 'com.apple.Safari', + isActive: true, + isOnScreen: true, + layer: 0, + frame: { + x: 0, + y: 0, + width: 1280, + height: 800 + } + } +]; +``` + +#### audioDevices() -> `Promise` Get a list of audio devices. @@ -68,6 +120,21 @@ Example: ]; ``` +#### externalDevices() -> `Promise` + +Get a list of external devices. + +Example: + +```js +[ + { + id: '9eb08da55a14244bf8044bf0f75247d2cb9c364c', + name: 'iPad Pro' + }, +]; +``` + #### videoCodecs -> `Map` Get a list of available video codecs. The key is the `videoCodec` option name and the value is the codec name. It only returns `hevc` if your computer supports HEVC hardware encoding. @@ -83,97 +150,148 @@ Map { } ``` -#### recorder +#### Audio Recording Options -#### recorder.startRecording([options?](#options)) +##### audioDeviceId -Returns a `Promise` that fullfills when the recording starts or rejects if the recording didn't start after 5 seconds. +Type: `string`\ +Default: `undefined` -#### recorder.isFileReady +Audio device to include in the screen recording. Should be one of the `id`'s from `aperture.audioDevices()`. -`Promise` that fullfills with the path to the screen recording file when it's ready. This will never reject. +##### losslessAudio -Only available while a recording is happening, `undefined` otherwise. +Type: `boolean`\ +Default: `false` -Usually, this resolves around 1 second before the recording starts, but that's not guaranteed. +Record audio in a lossless format (ALAC). Uses lossy (AAC) otherwise. -#### recorder.pause() +##### systemAudio -Pauses the recording. To resume, call `recorder.resume()`. +Type: `boolean`\ +Default: `false` -Returns a `Promise` that fullfills when the recording has been paused. +Record system audio. -#### recorder.resume() +#### Video Recording Options -Resumes the recording if it's been paused. +##### fps -Returns a `Promise` that fullfills when the recording has been resumed. +Type: `number`\ +Default: `30` -#### recorder.isPaused() +Number of frames per seconds. -Returns a `Promise` that resolves with a boolean indicating whether or not the recording is currently paused. +##### showCursor -#### recorder.stopRecording() +Type: `boolean`\ +Default: `true` -Returns a `Promise` for the path to the screen recording file. +Show the cursor in the screen recording. -## Options +##### highlightClicks -Type: `object` +Type: `boolean`\ +Default: `false` -#### fps +Highlight cursor clicks in the screen recording. -Type: `number`\ -Default: `30` +Enabling this will also enable the `showCursor` option. -Number of frames per seconds. +##### videoCodec + +Type: `string`\ +Default: `'h264'`\ +Values: `'hevc' | 'h264' | 'proRes422' | 'proRes4444'` + +A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. + +The [`proRes422` and `proRes4444`](https://documentation.apple.com/en/finalcutpro/professionalformatsandworkflows/index.html#chapter=10%26section=2%26tasks=true) codecs are uncompressed data. They will create huge files. + +#### recorder + +#### recorder.startRecordingScreen(options) + +Returns a `Promise` that fullfills when the recording starts. + +Accepts all [video](#video-recording-options) and [audio](#audio-recording-options) options, along with + +##### screenId -#### cropArea +Type: `string` + +The id of the screen to record. + +Should be one of the `id`'s from `screens()`. + +##### cropArea Type: `object`\ Default: `undefined` Record only an area of the screen. Accepts an object with `x`, `y`, `width`, `height` properties. -#### showCursor +#### recorder.startRecordingWindow(options) -Type: `boolean`\ -Default: `true` +Returns a `Promise` that fullfills when the recording starts. -Show the cursor in the screen recording. +Accepts all [video](#video-recording-options) and [audio](#audio-recording-options) options, along with -#### highlightClicks +##### windowId -Type: `boolean`\ -Default: `false` +Type: `string` -Highlight cursor clicks in the screen recording. +The id of the screen to record. -Enabling this will also enable the `showCursor` option. +Should be one of the `id`'s from `windows()`. -#### screenId +#### recorder.startRecordingExternalDevice(options) -Type: `number`\ -Default: `aperture.screens()[0]` _(Primary screen)_ +Returns a `Promise` that fullfills when the recording starts. -Screen to record. +Accepts all [video](#video-recording-options) and [audio](#audio-recording-options) options, along with -#### audioDeviceId +##### deviceId -Type: `string`\ -Default: `undefined` +Type: `string` -Audio device to include in the screen recording. Should be one of the `id`'s from `aperture.audioDevices()`. +The id of the screen to record. -#### videoCodec +Should be one of the `id`'s from `externalDevices()`. -Type: `string`\ -Default: `'h264'`\ -Values: `'hevc' | 'h264' | 'proRes422' | 'proRes4444'` +#### recorder.startRecordingAudio(options) -A computer with Intel 6th generation processor or newer is strongly recommended for the `hevc` codec, as otherwise it will use software encoding, which only produces 3 FPS fullscreen recording. +Returns a `Promise` that fullfills when the recording starts. -The [`proRes422` and `proRes4444`](https://documentation.apple.com/en/finalcutpro/professionalformatsandworkflows/index.html#chapter=10%26section=2%26tasks=true) codecs are uncompressed data. They will create huge files. +Accepts all [audio](#audio-recording-options) options + +#### recorder.isFileReady + +`Promise` that fullfills with the path to the screen recording file when it's ready. This will never reject. + +Only available while a recording is happening, `undefined` otherwise. + +Usually, this resolves around 1 second before the recording starts, but that's not guaranteed. + +#### recorder.pause() + +Pauses the recording. To resume, call `recorder.resume()`. + +Returns a `Promise` that fullfills when the recording has been paused. + +#### recorder.resume() + +Resumes the recording if it's been paused. + +Returns a `Promise` that fullfills when the recording has been resumed. + +#### recorder.isPaused() + +Returns a `Promise` that resolves with a boolean indicating whether or not the recording is currently paused. + +#### recorder.stopRecording() + +Returns a `Promise` for the path to the screen recording file. ## Why From 054a28e8ec188c2b087919f5d5f796b637a3a699 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Wed, 20 Nov 2024 19:44:37 -0500 Subject: [PATCH 21/27] Fix types and example --- example.js | 12 +++++++----- index.d.ts | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/example.js b/example.js index 8e62318..4726912 100644 --- a/example.js +++ b/example.js @@ -2,18 +2,20 @@ import fs from 'node:fs'; import timers from 'node:timers/promises'; import { recorder, - screens, - audioDevices, + screens as getScreens, + audioDevices as getAudioDevices, videoCodecs, } from './index.js'; async function main() { - console.log('Screens:', await screens()); - console.log('Audio devices:', await audioDevices()); + const screens = await getScreens(); + console.log('Screens:', screens); + const audioDevices = await getAudioDevices(); + console.log('Audio devices:', audioDevices); console.log('Video codecs:', videoCodecs); console.log('Preparing to record for 5 seconds'); - await recorder.startRecording(); + await recorder.startRecordingScreen({screenId: screens[0].id, audioDeviceId: audioDevices[0].id}); console.log('Recording started'); await recorder.isFileReady; console.log('File is ready'); diff --git a/index.d.ts b/index.d.ts index 79df197..117f158 100644 --- a/index.d.ts +++ b/index.d.ts @@ -245,8 +245,8 @@ Get a list of windows. [{ id: '69732482', title: 'Unicorn', - applicationName: 'Safari', - applicationBundleIdentifier: 'com.apple.Safari', + appName: 'Safari', + appBundleIdentifier: 'com.apple.Safari', isActive: true, isOnScreen: true, layer: 0, From ff7cbef4d4d2af66adbef3b613f78d1baf4dae58 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Thu, 21 Nov 2024 20:29:40 -0500 Subject: [PATCH 22/27] Cleanup exports --- package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package.json b/package.json index 7d65ce8..d8ace41 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,6 @@ ".": { "types": "./index.d.ts", "default": "./index.js" - }, - "./native": { - "types": "./index.d.ts", - "default": "./native.js" } }, "sideEffects": false, From 335b643066abf028e12b0d22a430876625d6b5a8 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Thu, 21 Nov 2024 20:32:02 -0500 Subject: [PATCH 23/27] Version bump --- Package.resolved | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 3684df1..e2b8f56 100644 --- a/Package.resolved +++ b/Package.resolved @@ -6,7 +6,7 @@ "location" : "https://github.com/wulkano/Aperture", "state" : { "branch" : "george/rewrite-in-screen-capture-kit", - "revision" : "d89d3e4b827b1e284388788976be79e3ee3cb798" + "revision" : "301e68ab150d9173c9fdd62d6d1f76ed671ab9dd" } }, { From a6b14d719e04a30d78860ff251a014c39d330d59 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Fri, 22 Nov 2024 09:17:08 -0500 Subject: [PATCH 24/27] Bump Aperture to 3.0.0 --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index e2b8f56..a6651e4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wulkano/Aperture", "state" : { - "branch" : "george/rewrite-in-screen-capture-kit", - "revision" : "301e68ab150d9173c9fdd62d6d1f76ed671ab9dd" + "revision" : "7591bb540c844fe6c47edeac34b17c25e92a717f", + "version" : "3.0.0" } }, { diff --git a/Package.swift b/Package.swift index 319ec17..c10d9e2 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/wulkano/Aperture", branch: "george/rewrite-in-screen-capture-kit"), + .package(url: "https://github.com/wulkano/Aperture", from: "3.0.0"), .package(path: "node_modules/node-swift") ], targets: [ From 9799d31722f03eee0d510e757e3cc2e6c2b6bddc Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Fri, 22 Nov 2024 09:49:45 -0500 Subject: [PATCH 25/27] Fix some errors and docs --- index.js | 2 +- readme.md | 17 +++++++++++++++++ utils.js | 4 ++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index ed8f6b9..803e919 100644 --- a/index.js +++ b/index.js @@ -100,7 +100,7 @@ export class Recorder { throwIfNotStarted() { if (this.recorder === undefined) { - throw new Error('Call `.startRecording()` first'); + throw new Error('Recording not started yet'); } } diff --git a/readme.md b/readme.md index 5ddfa20..e04c7d0 100644 --- a/readme.md +++ b/readme.md @@ -208,6 +208,23 @@ A computer with Intel 6th generation processor or newer is strongly recommended The [`proRes422` and `proRes4444`](https://documentation.apple.com/en/finalcutpro/professionalformatsandworkflows/index.html#chapter=10%26section=2%26tasks=true) codecs are uncompressed data. They will create huge files. +##### extension + +Type: `string`\ +Default: + +- `'m4a'` for [audio](#recorderstartrecordingaudiooptions) recordings +- `'mov'` for `proRes422` and `proRes4444` video codecs +- `'mp4'` otherwise + +Values: + +- `'m4a'` is the only valid option for [audio](#recorderstartrecordingaudiooptions) recordings +- `'mov'` is the only valid option for `proRes422` and `proRes4444` video codecs +- `'mp4' | 'm4v' | 'mov'` for all other video codecs + +The extension of the output file + #### recorder #### recorder.startRecordingScreen(options) diff --git a/utils.js b/utils.js index ddb5576..7dd66f0 100644 --- a/utils.js +++ b/utils.js @@ -80,6 +80,10 @@ export function normalizeOptions(targetType, { recorderOptions.videoCodec = videoCodec; } + if (targetType === 'audioOnly' && extension !== 'm4a') { + throw new Error('Audio recordings only supports the m4a extension'); + } + const temporaryPath = temporaryFile({ extension: targetType === 'audioOnly' ? 'm4a' : extension, }); From f5c1cc797cd8656cd48cb6e00b01ed1a87ba51a7 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Fri, 22 Nov 2024 10:01:47 -0500 Subject: [PATCH 26/27] Rename targetId -> targetID --- Sources/ApertureNode/ApertureNode.swift | 2 +- index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ApertureNode/ApertureNode.swift b/Sources/ApertureNode/ApertureNode.swift index c450d02..fc0c12c 100644 --- a/Sources/ApertureNode/ApertureNode.swift +++ b/Sources/ApertureNode/ApertureNode.swift @@ -238,7 +238,7 @@ extension NodeObject { return Aperture.RecordingOptions( destination: destination, - targetID: try getAs("targetId", type: String.self), + targetID: try getAs("targetID", type: String.self), framesPerSecond: try getAs("framesPerSecond", type: Int.self) ?? 60, cropRect: try getAs("cropRect", type: NodeObject.self)?.asCGRect(), showCursor: try getAs("showCursor", type: Bool.self) ?? true, diff --git a/index.js b/index.js index 803e919..968f71c 100644 --- a/index.js +++ b/index.js @@ -88,7 +88,7 @@ export class Recorder { } if (recorderOptions.targetId) { - finalOptions.targetId = recorderOptions.targetId; + finalOptions.targetID = recorderOptions.targetId; } if (recorderOptions.audioDeviceId) { From 3b8ee06743e2ebcdb12e73410b34e79625a50e17 Mon Sep 17 00:00:00 2001 From: George Karagkiaouris Date: Fri, 22 Nov 2024 10:17:53 -0500 Subject: [PATCH 27/27] Remove unused dependencies --- package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/package.json b/package.json index d8ace41..75c2884 100644 --- a/package.json +++ b/package.json @@ -30,15 +30,13 @@ "build" ], "dependencies": { - "delay": "^6.0.0", - "electron-util": "^0.18.1", - "execa": "^8.0.1", "file-url": "^4.0.0", "macos-version": "^6.0.0", "tempy": "^3.1.0" }, "devDependencies": { "ava": "^6.1.2", + "delay": "^6.0.0", "file-type": "^19.0.0", "node-swift": "github:kabiroberai/node-swift#1.3.0", "read-chunk": "^4.0.3",