Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Require macOS 10.13 and add support for pause/resume #14

Merged
merged 10 commits into from
Mar 13, 2021
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ yarn.lock
/Packages
/*.xcodeproj
/aperture

recording.mp4
13 changes: 11 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@
"repositoryURL": "https://github.com/wulkano/Aperture",
"state": {
"branch": null,
"revision": "2447e76fac46f3317544a367f942356f4f5df21c",
"version": "0.2.0"
"revision": "2b347d60d58ce87f5ebe0269924efabcf8bed9c0",
"version": "0.4.0"
}
},
{
"package": "swift-argument-parser",
"repositoryURL": "https://github.com/apple/swift-argument-parser",
"state": {
"branch": null,
"revision": "831ed5e860a70e745bc1337830af4786b2576881",
"version": "0.4.1"
}
}
]
Expand Down
10 changes: 6 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// swift-tools-version:5.2
// swift-tools-version:5.3
import PackageDescription

let package = Package(
name: "ApertureCLI",
platforms: [
.macOS(.v10_12)
.macOS(.v10_13)
],
products: [
.executable(
Expand All @@ -15,13 +15,15 @@ let package = Package(
)
],
dependencies: [
.package(url: "https://github.com/wulkano/Aperture", from: "0.2.0")
.package(url: "https://github.com/wulkano/Aperture", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "0.4.0")
],
targets: [
.target(
name: "ApertureCLI",
dependencies: [
"Aperture"
"Aperture",
.product(name: "ArgumentParser", package: "swift-argument-parser")
]
)
]
Expand Down
201 changes: 140 additions & 61 deletions Sources/ApertureCLI/main.swift
Original file line number Diff line number Diff line change
@@ -1,83 +1,162 @@
import Foundation
import AVFoundation
import Aperture
import ArgumentParser

struct Options: Decodable {
let destination: URL
let framesPerSecond: Int
let cropRect: CGRect?
let showCursor: Bool
let highlightClicks: Bool
let screenId: CGDirectDisplayID
let audioDeviceId: String?
let videoCodec: String?
enum OutEvent: String, CaseIterable, ExpressibleByArgument {
case onStart
case onFileReady
case onPause
case onResume
case onFinish
}

func record() throws {
setbuf(__stdoutp, nil)
enum InEvent: String, CaseIterable, ExpressibleByArgument {
case pause
case resume
case isPaused
case onPause
}

let options: Options = try CLI.arguments.first!.jsonDecoded()
extension CaseIterable {
static func toStringArray() -> String {
return allCases.map { "\($0)" }.joined(separator: ", ")
}
}

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
struct ApertureCLI: ParsableCommand {
static var configuration = CommandConfiguration(
commandName: "aperture",
subcommands: [List.self, Record.self, Events.self]
)
}

recorder.onStart = {
print("FR")
extension ApertureCLI {
struct List: ParsableCommand {
static var configuration = CommandConfiguration(
subcommands: [Screens.self, AudioDevices.self]
)
}

recorder.onFinish = {
exit(0)
}
struct Record: ParsableCommand {
static var configuration = CommandConfiguration(abstract: "Start a recording with the given options.")

recorder.onError = {
print($0, to: .standardError)
exit(1)
@Option(name: .shortAndLong, help: "The ID to use for this process")
var processId: String = "main"

@Argument(help: "Stringified JSON object with options passed to Aperture")
var options: String

mutating func run() throws {
try record(options, processId: processId)
}
}

CLI.onExit = {
recorder.stop()
// Do not call `exit()` here as the video is not always done
// saving at this point and will be corrupted randomly
struct Events: ParsableCommand {
static var configuration = CommandConfiguration(
subcommands: [Send.self, Listen.self, ListenAll.self]
)
}
}

recorder.start()
extension ApertureCLI.List {
struct Screens: ParsableCommand {
static var configuration = CommandConfiguration(abstract: "List available screens.")

// Inform the Node.js code that the recording has started.
print("R")
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)
}
}

RunLoop.main.run()
}
struct AudioDevices: ParsableCommand {
static var configuration = CommandConfiguration(abstract: "List available audio devices.")

func showUsage() {
print(
"""
Usage:
aperture <options>
aperture list-screens
aperture list-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)
}
}
}

switch CLI.arguments.first {
case "list-screens":
print(try toJson(Devices.screen()), to: .standardError)
exit(0)
case "list-audio-devices":
// Uses stderr because of unrelated stuff being outputted on stdout
print(try toJson(Devices.audio()), to: .standardError)
exit(0)
case .none:
showUsage()
exit(1)
default:
try record()
extension ApertureCLI.Events {
struct Send: ParsableCommand {
static var configuration = CommandConfiguration(abstract: "Send an event to the given process.")

@Flag(inversion: .prefixedNo, help: "Wait for event to be received")
var wait: Bool = true

@Option(name: .shortAndLong, help: "The ID of the target process")
var processId: String = "main"

@Argument(help: "Name of the event to send. Can be one of:\n\(InEvent.toStringArray())")
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 var 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: String = "main"

@Argument(help: "Name of the event to listen for. Can be one of:\n\(OutEvent.toStringArray())")
var event: OutEvent

func run() {
_ = ApertureEvents.answerEvent(processId: processId, event: event.rawValue) { notification in
if let data = notification.data {
print(data)
}

if self.exit {
notification.answer()
Foundation.exit(0)
}
}

RunLoop.main.run()
}
}

struct ListenAll: ParsableCommand {
static var configuration = CommandConfiguration(abstract: "Listen to all outcoming events for the given process.")

@Option(name: .shortAndLong, help: "The ID of the target process")
var processId: String = "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()
}
}
}

ApertureCLI.main()
110 changes: 110 additions & 0 deletions Sources/ApertureCLI/notifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import Foundation

final class ApertureNotification {
static func notificationName(forEvent event: String, processId: String) -> String {
return "aperture.\(processId).\(event)"
}

private var notification: Notification
var isAnswered = false

init(_ notification: Notification) {
self.notification = notification
}

func getField<T>(_ name: String) -> T? {
return notification.userInfo?[name] as? T
}

var data: String? {
return getField("data")
}

func answer(_ data: Any? = nil) {
isAnswered = true

let responseIdentifier: String? = getField("responseIdentifier")

guard responseIdentifier != nil 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 {
return 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 }
}
}
Loading