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