diff --git a/index.js b/index.js index 5a5fab7..3b6487c 100644 --- a/index.js +++ b/index.js @@ -64,7 +64,7 @@ class Aperture { const recorderOpts = { destination: fileUrl(this.tmpPath), - fps, + framesPerSecond: fps, showCursor, highlightClicks, screenId, diff --git a/swift/aperture/Recorder.swift b/swift/aperture/Recorder.swift index 30d2347..15106bb 100644 --- a/swift/aperture/Recorder.swift +++ b/swift/aperture/Recorder.swift @@ -29,7 +29,7 @@ final class Recorder: NSObject { /// TODO: When targeting macOS 10.13, make the `videoCodec` option the type `AVVideoCodecType` init( destination: URL, - fps: Int, + framesPerSecond: Int, cropRect: CGRect?, showCursor: Bool, highlightClicks: Bool, @@ -42,8 +42,7 @@ final class Recorder: NSObject { let input = AVCaptureScreenInput(displayID: screenId) - /// TODO: Use `CMTime(seconds:)` here instead - input.minFrameDuration = CMTime(value: 1, timescale: Int32(fps)) + input.minFrameDuration = CMTime(videoFramesPerSecond: framesPerSecond) if let cropRect = cropRect { input.cropRect = cropRect diff --git a/swift/aperture/main.swift b/swift/aperture/main.swift index f983802..023b05e 100644 --- a/swift/aperture/main.swift +++ b/swift/aperture/main.swift @@ -1,18 +1,9 @@ import Foundation import AVFoundation -var recorder: Recorder! -let arguments = CommandLine.arguments.dropFirst() - -func quit(_: Int32) { - recorder.stop() - // Do not call `exit()` here as the video is not always done - // saving at this point and will be corrupted randomly -} - struct Options: Decodable { let destination: URL - let fps: Int + let framesPerSecond: Int let cropRect: CGRect? let showCursor: Bool let highlightClicks: Bool @@ -22,12 +13,11 @@ struct Options: Decodable { } func record() throws { - let json = arguments.first!.data(using: .utf8)! - let options = try JSONDecoder().decode(Options.self, from: json) + let options: Options = try CLI.arguments.first!.jsonDecoded() - recorder = try Recorder( + let recorder = try Recorder( destination: options.destination, - fps: options.fps, + framesPerSecond: options.framesPerSecond, cropRect: options.cropRect, showCursor: options.showCursor, highlightClicks: options.highlightClicks, @@ -45,22 +35,23 @@ func record() throws { } recorder.onError = { - printErr($0) + print($0, to: .standardError) exit(1) } - signal(SIGHUP, quit) - signal(SIGINT, quit) - signal(SIGTERM, quit) - signal(SIGQUIT, quit) + 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 + } recorder.start() - setbuf(__stdoutp, nil) + setbuf(__stdoutp, nil) RunLoop.main.run() } -func usage() { +func showUsage() { print( """ Usage: @@ -71,21 +62,17 @@ func usage() { ) } -if arguments.first == "list-screens" { - printErr(try toJson(DeviceList.screen())) +switch CLI.arguments.first { +case "list-screens": + print(try toJson(DeviceList.screen()), to: .standardError) exit(0) -} - -if arguments.first == "list-audio-devices" { +case "list-audio-devices": // Uses stderr because of unrelated stuff being outputted on stdout - printErr(try toJson(DeviceList.audio())) + print(try toJson(DeviceList.audio()), to: .standardError) exit(0) -} - -if arguments.first != nil { +case .none: + showUsage() + exit(1) +default: try record() - exit(0) } - -usage() -exit(1) diff --git a/swift/aperture/util.swift b/swift/aperture/util.swift index 045d8b2..ac04e15 100644 --- a/swift/aperture/util.swift +++ b/swift/aperture/util.swift @@ -1,16 +1,236 @@ import AppKit import AVFoundation -private final class StandardErrorOutputStream: TextOutputStream { - func write(_ string: String) { - FileHandle.standardError.write(string.data(using: .utf8)!) + +// MARK: - SignalHandler +struct 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 + } + } + + 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 Array where Element == 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)!) + } +} + +struct CLI { + static var standardInput = FileHandle.standardOutput + 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) + } } } -private var stderr = StandardErrorOutputStream() +enum PrintOutputTarget { + case standardOutput + case standardError +} -func printErr(_ item: Any) { - print(item, to: &stderr) +/// 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 { + return try JSONDecoder().decode(T.self, from: self) + } +} + +extension String { + func jsonDecoded() throws -> T { + return try data(using: .utf8)!.jsonDecoded() + } } func toJson(_ data: T) throws -> String { @@ -18,9 +238,17 @@ func toJson(_ data: T) throws -> String { return String(data: json, encoding: .utf8)! } +extension CMTimeScale { + static var video: CMTimeScale = 600 // This is what Apple recommends +} + extension CMTime { - static var zero: CMTime = kCMTimeZero - static var invalid: CMTime = kCMTimeInvalid + static let zero = kCMTimeZero + static let invalid = kCMTimeInvalid + + init(videoFramesPerSecond: Int) { + self.init(seconds: 1 / Double(videoFramesPerSecond), preferredTimescale: .video) + } } extension CGDirectDisplayID { @@ -77,3 +305,4 @@ extension NSScreen { return name } } +// MARK: -