+import AVFoundation
+import Cocoa
+import Combine
+import Sparkle
/*
Copyright (C) 2024 Rubén Beltrán del Río
along with this program. If not, see https://captura.tranquil.systems.
*/
import SwiftUI
-import Cocoa
-import Combine
-import AVFoundation
-import Sparkle
@main
struct CapturaApp: App {
- @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
+ @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
- var body: some Scene {
- WindowGroup {
- PreferencesScreen()
- .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*"))
- .frame(width: 650, height: 450)
- }
- .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
- //.modelContainer(for: CapturaRemoteFile.self)
- }
+ var body: some Scene {
+ WindowGroup {
+ PreferencesScreen()
+ .handlesExternalEvents(
+ preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*")
+ )
+ .frame(width: 650, height: 450)
+ }
+ .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
+ //.modelContainer(for: CapturaRemoteFile.self)
+ }
}
-@objc(CapturaAppDelegate) class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
-
+@objc(CapturaAppDelegate) class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate
+{
+
@Environment(\.openURL) var openURL
var statusItem: NSStatusItem!
var captureState: CaptureState = .idle
var stopTimer: DispatchWorkItem?
var remoteFiles: [CapturaRemoteFile] = []
var captureSessionConfiguration: CaptureSessionConfiguration = CaptureSessionConfiguration()
-
+
// Sparkle Configuration
@IBOutlet var checkForUpdatesMenuItem: NSMenuItem!
- let updaterController: SPUStandardUpdaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
-
+ let updaterController: SPUStandardUpdaterController = SPUStandardUpdaterController(
+ startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
+
@objc dynamic var scriptedPreferences: ScriptedPreferences = ScriptedPreferences()
-
+
func applicationDidFinishLaunching(_ notification: Notification) {
setupStatusBar()
NotificationCenter.default.addObserver(
closeWindow()
fetchRemoteItems()
}
-
+
// MARK: - Setup Functions
-
-
+
private func setupStatusBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
-
+
if let button = statusItem.button {
button.image = NSImage(named: "Idle")
}
-
+
statusItem.isVisible = true
statusItem.menu = NSMenu()
statusItem.menu?.delegate = self
-
+
// Create the Popover
popover = NSPopover()
popover?.contentViewController = HelpPopoverViewController()
popover?.behavior = .transient
-
+
setupMenu()
}
-
+
private func setupMenu() {
-
+
statusItem.menu?.removeAllItems()
-
- statusItem.menu?.addItem(NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: ""))
- if (remoteFiles.count > 0) {
+
+ statusItem.menu?.addItem(
+ NSMenuItem(
+ title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording),
+ keyEquivalent: ""))
+ if remoteFiles.count > 0 {
statusItem.menu?.addItem(NSMenuItem.separator())
for remoteFile in remoteFiles {
- let remoteFileItem = NSMenuItem(title: remoteFile.name, action: #selector(CapturaAppDelegate.onClickRemoteFile), keyEquivalent: "")
+ let remoteFileItem = NSMenuItem(
+ title: remoteFile.name, action: #selector(CapturaAppDelegate.onClickRemoteFile),
+ keyEquivalent: "")
remoteFileItem.representedObject = remoteFile
statusItem.menu?.addItem(remoteFileItem)
}
}
statusItem.menu?.addItem(NSMenuItem.separator())
- statusItem.menu?.addItem(NSMenuItem(title: "Open Local Folder", action: #selector(CapturaAppDelegate.onOpenFolder), keyEquivalent: ""))
+ statusItem.menu?.addItem(
+ NSMenuItem(
+ title: "Open Local Folder", action: #selector(CapturaAppDelegate.onOpenFolder),
+ keyEquivalent: ""))
statusItem.menu?.addItem(NSMenuItem.separator())
-
- checkForUpdatesMenuItem = NSMenuItem(title: "Check for Updates", action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), keyEquivalent: "")
+
+ checkForUpdatesMenuItem = NSMenuItem(
+ title: "Check for Updates",
+ action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), keyEquivalent: "")
checkForUpdatesMenuItem.target = updaterController
statusItem.menu?.addItem(checkForUpdatesMenuItem)
-
- statusItem.menu?.addItem(NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: ""))
- statusItem.menu?.addItem(NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: ""))
+
+ statusItem.menu?.addItem(
+ NSMenuItem(
+ title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences),
+ keyEquivalent: ""))
+ statusItem.menu?.addItem(
+ NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: ""))
}
-
+
private func closeWindow() {
if let window = NSApplication.shared.windows.first {
window.close()
}
// MARK: - URL Event Handler
-
+
func application(_ application: NSApplication, open urls: [URL]) {
- if (CapturaSettings.shouldAllowURLAutomation) {
+ if CapturaSettings.shouldAllowURLAutomation {
for url in urls {
if let action = CapturaURLDecoder.decodeParams(url: url) {
switch action {
- case let .configure(config):
- NotificationCenter.default.post(name: .setConfiguration, object: nil, userInfo: [
- "config": config
- ])
- case let .record(config):
- NotificationCenter.default.post(name: .setCaptureSessionConfiguration, object: nil, userInfo: [
- "config": config
- ])
+ case let .configure(config):
+ NotificationCenter.default.post(
+ name: .setConfiguration, object: nil,
+ userInfo: [
+ "config": config
+ ])
+ case let .record(config):
+ NotificationCenter.default.post(
+ name: .setCaptureSessionConfiguration, object: nil,
+ userInfo: [
+ "config": config
+ ])
NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
}
}
} else {
let alert = NSAlert()
alert.messageText = "URL Automation Prevented"
- alert.informativeText = "A website or application attempted to record your screen using URL Automation. If you want to allow this, enable it in Preferences."
- alert.alertStyle = .warning
- alert.addButton(withTitle: "OK")
- alert.runModal()
+ alert.informativeText =
+ "A website or application attempted to record your screen using URL Automation. If you want to allow this, enable it in Preferences."
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
}
}
-
+
// MARK: - UI Event Handlers
-
+
func menuWillOpen(_ menu: NSMenu) {
if captureState != .idle {
menu.cancelTrackingWithoutAnimation()
}
}
}
-
+
@objc private func onClickStartRecording() {
NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
}
-
+
@objc private func onOpenPreferences() {
NSApp.activate(ignoringOtherApps: true)
if preferencesWindow == nil {
- preferencesWindow = PreferencesWindow()
+ preferencesWindow = PreferencesWindow()
} else {
preferencesWindow?.makeKeyAndOrderFront(nil)
preferencesWindow?.orderFrontRegardless()
}
}
-
+
@objc private func onOpenFolder() {
- if let directory = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first?.appendingPathComponent("captura") {
+ if let directory = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first?
+ .appendingPathComponent("captura")
+ {
NSWorkspace.shared.open(directory)
}
}
-
+
@objc private func onClickRemoteFile(_ sender: NSMenuItem) {
if let remoteFile = sender.representedObject as? CapturaRemoteFile {
if let urlString = remoteFile.url {
}
}
}
-
+
@objc private func onQuit() {
NSApplication.shared.terminate(self)
}
-
+
// MARK: - App State Event Listeners
-
+
@objc func didReceiveNotification(_ notification: Notification) {
- switch(notification.name) {
+ switch notification.name {
case .startAreaSelection:
startAreaSelection()
case .startRecording:
}
}
case .reloadConfiguration:
- reloadConfiguration()
+ reloadConfiguration()
case .setCaptureSessionConfiguration:
if let userInfo = notification.userInfo {
if let config = userInfo["config"] as? RecordAction {
return
}
}
-
-
+
func startAreaSelection() {
helpShown = false
if captureState != .selectingArea {
}
}
}
-
+
func startRecording() {
captureState = .recording
updateImage()
outputFile = nil
- images = [];
+ images = []
pixelDensity = recordingWindow?.pixelDensity ?? 1.0
recordingWindow?.recordingContentView.startRecording()
if let box = recordingWindow?.recordingContentView.box {
if let screen = recordingWindow?.screen {
captureSession = CapturaCaptureSession(screen, box: box)
-
+
if let captureSession {
stopTimer = DispatchWorkItem {
self.stopRecording()
}
- DispatchQueue.main.asyncAfter(deadline: .now() + Double(captureSessionConfiguration.maxLength), execute: stopTimer!)
-
+ DispatchQueue.main.asyncAfter(
+ deadline: .now() + Double(captureSessionConfiguration.maxLength), execute: stopTimer!)
+
outputFile = CapturaFile()
if captureSessionConfiguration.shouldSaveMp4 {
captureSession.startRecording(to: outputFile!.mp4URL)
}
NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil)
}
-
+
func stopRecording() {
captureState = .uploading
updateImage()
stop()
-
+
Task.detached {
if self.captureSessionConfiguration.shouldSaveGif {
if let outputFile = self.outputFile {
- await GifRenderer.render(self.images, at: self.captureSessionConfiguration.frameRate, to: outputFile.gifURL)
+ await GifRenderer.render(
+ self.images, at: self.captureSessionConfiguration.frameRate, to: outputFile.gifURL)
}
}
let wasSuccessful = await self.uploadOrCopy()
}
}
}
-
+
func finalizeRecording() {
captureState = .uploaded
updateImage()
NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
}
}
-
+
func reset() {
captureState = .idle
updateImage()
captureSessionConfiguration = CaptureSessionConfiguration()
stop()
}
-
+
func receivedFrame(_ frame: CVImageBuffer) {
let now = ContinuousClock.now
-
- if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(captureSessionConfiguration.frameRate)) {
+
+ if now - gifCallbackTimer
+ > .nanoseconds(1_000_000_000 / UInt64(captureSessionConfiguration.frameRate))
+ {
gifCallbackTimer = now
DispatchQueue.main.async {
if var cgImage = frame.cgImage {
}
}
}
-
+
func failed(_ requestPermission: Bool = false) {
captureState = .error
updateImage()
NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
}
}
-
+
func setConfiguration(_ config: ConfigureAction) {
CapturaSettings.apply(config)
}
-
+
func reloadConfiguration() {
self.captureSessionConfiguration = CaptureSessionConfiguration()
}
-
+
func setCaptureSessionConfiguration(_ config: RecordAction) {
self.captureSessionConfiguration = CaptureSessionConfiguration(from: config)
}
-
+
// MARK: - CoreData
-
+
private func fetchRemoteItems() {
let viewContext = PersistenceController.shared.container.viewContext
let fetchRequest = NSFetchRequest<CapturaRemoteFile>(entityName: "CapturaRemoteFile")
fetchRequest.fetchLimit = 5
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)]
-
+
let results = try? viewContext.fetch(fetchRequest)
remoteFiles = results ?? []
}
-
+
// MARK: - Presentation Helpers
-
-
+
private func requestPermissionToRecord() {
showPopoverWithMessage("Please grant Captura permission to record")
- if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") {
+ if let url = URL(
+ string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording")
+ {
NSWorkspace.shared.open(url)
}
}
-
+
private func showPopoverWithMessage(_ message: String) {
if let button = statusItem.button {
(self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message)
}
}
}
-
+
private func updateImage() {
if let button = statusItem.button {
- let image: String = switch captureState {
- case .idle:
- "Idle"
- case .selectingArea:
- if recordingWindow?.recordingContentView.box != nil {
- "Ready to Record"
- } else {
- "Selecting"
+ let image: String =
+ switch captureState {
+ case .idle:
+ "Idle"
+ case .selectingArea:
+ if recordingWindow?.recordingContentView.box != nil {
+ "Ready to Record"
+ } else {
+ "Selecting"
+ }
+ case .recording:
+ "Stop Frame 1"
+ case .uploading:
+ "Upload Frame 1"
+ case .uploaded:
+ "OK"
+ case .error:
+ "ERR"
}
- case .recording:
- "Stop Frame 1"
- case .uploading:
- "Upload Frame 1"
- case .uploaded:
- "OK"
- case .error:
- "ERR"
- }
button.image = NSImage(named: image)
}
}
-
+
private func stop() {
stopTimer?.cancel()
captureSession?.stopRunning()
recordingWindow?.close()
recordingWindow = nil
}
-
+
private func uploadOrCopy() async -> Bool {
if captureSessionConfiguration.shouldUseBackend {
let result = await uploadToBackend()
return true
}
}
-
+
private func copyLocalToClipboard() {
- let fileType: NSPasteboard.PasteboardType = .init(rawValue: captureSessionConfiguration.shouldSaveGif ? "com.compuserve.gif" : "public.mpeg-4")
- if let url = captureSessionConfiguration.shouldSaveGif ? outputFile?.gifURL : outputFile?.mp4URL {
+ let fileType: NSPasteboard.PasteboardType = .init(
+ rawValue: captureSessionConfiguration.shouldSaveGif ? "com.compuserve.gif" : "public.mpeg-4")
+ if let url = captureSessionConfiguration.shouldSaveGif ? outputFile?.gifURL : outputFile?.mp4URL
+ {
if let data = try? Data(contentsOf: url) {
let pasteboard = NSPasteboard.general
pasteboard.declareTypes([fileType], owner: nil)
}
}
}
-
+
private func uploadToBackend() async -> Bool {
let contentType = captureSessionConfiguration.shouldUploadGif ? "image/gif" : "video/mp4"
- if let url = captureSessionConfiguration.shouldUploadGif ? outputFile?.gifURL : outputFile?.mp4URL {
+ if let url = captureSessionConfiguration.shouldUploadGif
+ ? outputFile?.gifURL : outputFile?.mp4URL
+ {
if let data = try? Data(contentsOf: url) {
if let remoteUrl = captureSessionConfiguration.backend {
var request = URLRequest(url: remoteUrl)
request.httpBody = data
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
request.setValue("Captura/1.0", forHTTPHeaderField: "User-Agent")
-
+
do {
let (data, response) = try await URLSession.shared.data(for: request)
-
+
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 201 {
let answer = try JSONDecoder().decode(BackendResponse.self, from: data)
}
return false
}
-
+
private func createRemoteFile(_ url: URL) {
let viewContext = PersistenceController.shared.container.viewContext
let remoteFile = CapturaRemoteFile(context: viewContext)
pasteboard.declareTypes([.URL], owner: nil)
pasteboard.setString(url.absoluteString, forType: .string)
}
-
+
private func deleteLocalFiles() {
if captureSessionConfiguration.shouldSaveGif {
if let url = outputFile?.gifURL {
- try? FileManager.default.removeItem(at: url)
+ try? FileManager.default.removeItem(at: url)
}
}
if captureSessionConfiguration.shouldSaveMp4 {
func resize(by scale: CGFloat) -> CGImage? {
let width = Int(CGFloat(self.width) / scale)
let height = Int(CGFloat(self.height) / scale)
-
+
let bitsPerComponent = self.bitsPerComponent
let colorSpace = self.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!
let bitmapInfo = self.bitmapInfo.rawValue
-
- guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: 0, space: colorSpace, bitmapInfo: bitmapInfo) else {
- return nil
+
+ guard
+ let context = CGContext(
+ data: nil, width: width, height: height, bitsPerComponent: bitsPerComponent, bytesPerRow: 0,
+ space: colorSpace, bitmapInfo: bitmapInfo)
+ else {
+ return nil
}
-
+
context.interpolationQuality = .high
context.draw(self, in: CGRect(x: 0, y: 0, width: width, height: height))
-
+
return context.makeImage()
}
}
import ReplayKit
extension CVImageBuffer {
-
+
private static let contextQueue = DispatchQueue(label: "com.example.contextQueue")
static let sharedContext: CIContext = {
- return CIContext()
+ return CIContext()
}()
-
+
var cgImage: CGImage? {
var result: CGImage?
CVImageBuffer.contextQueue.sync {
let ciImage = CIImage(cvImageBuffer: self)
let width = CVPixelBufferGetWidth(self)
let height = CVPixelBufferGetHeight(self)
- result = CVImageBuffer.sharedContext.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: width, height: height))
+ result = CVImageBuffer.sharedContext.createCGImage(
+ ciImage, from: CGRect(x: 0, y: 0, width: width, height: height))
}
return result
}
along with this program. If not, see https://captura.tranquil.systems.
*/
import Foundation
+
struct BackendResponse: Decodable {
- let url: URL
+ let url: URL
}
import Foundation
struct CapturaFile {
-
+
let name: String
let baseDirectory: URL
let appDirectory: String = "captura"
-
+
private var baseURL: URL {
baseDirectory.appendingPathComponent("\(appDirectory)/\(name)")
}
-
+
var mp4URL: URL {
return baseURL.appendingPathExtension("mp4")
}
-
+
var gifURL: URL {
return baseURL.appendingPathExtension("gif")
}
-
+
init() {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .medium
dateFormatter.locale = Locale.current
let dateString = dateFormatter.string(from: Date()).replacingOccurrences(of: ":", with: ".")
-
+
self.name = "Captura \(dateString)"
- self.baseDirectory = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first!
- try? FileManager.default.createDirectory(at: self.baseDirectory.appendingPathComponent(appDirectory),
- withIntermediateDirectories: true,
- attributes: nil)
+ self.baseDirectory = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask)
+ .first!
+ try? FileManager.default.createDirectory(
+ at: self.baseDirectory.appendingPathComponent(appDirectory),
+ withIntermediateDirectories: true,
+ attributes: nil)
}
}
UserDefaults.standard.setValue(newValue, forKey: "frameRate")
}
}
-
+
static var outputFormats: OutputFormatSetting {
get {
OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
UserDefaults.standard.setValue(newValue.rawValue, forKey: "outputFormats")
}
}
-
+
static var shouldSaveMp4: Bool {
outputFormats.shouldSaveMp4() || (shouldUseBackend && shouldUploadMp4)
}
-
+
static var shouldSaveGif: Bool {
outputFormats.shouldSaveGif() || (shouldUseBackend && shouldUploadGif)
}
-
+
static var shouldUploadGif: Bool {
backendFormat.shouldSaveGif()
}
-
+
static var shouldUploadMp4: Bool {
backendFormat.shouldSaveMp4()
}
-
+
static var shouldUseBackend: Bool {
backend != nil
}
-
+
static var backend: URL? {
get {
if let url = UserDefaults.standard.string(forKey: "backendUrl") {
UserDefaults.standard.setValue(newValue?.absoluteString, forKey: "backendUrl")
}
}
-
+
static var backendFormat: OutputFormatSetting {
get {
- OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "backendFormat")) ?? .gifOnly
+ OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "backendFormat"))
+ ?? .gifOnly
}
set {
UserDefaults.standard.setValue(newValue.rawValue, forKey: "backendFormat")
}
}
-
+
static var shouldKeepLocalFiles: Bool {
get {
if UserDefaults.standard.object(forKey: "keepFiles") == nil {
UserDefaults.standard.set(newValue, forKey: "keepFiles")
}
}
-
+
static var shouldAllowURLAutomation: Bool {
get {
UserDefaults.standard.bool(forKey: "allowURLAutomation")
UserDefaults.standard.setValue(newValue, forKey: "allowURLAutomation")
}
}
-
+
static func apply(_ config: ConfigureAction) {
if let fps = config.fps {
frameRate = fps
protocol RecordActionProtocol {
var action: String { get }
-
+
var x: Int? { get }
var y: Int? { get }
var width: Int? { get }
}
enum CapturaAction {
- case record(RecordAction)
- case configure(ConfigureAction)
+ case record(RecordAction)
+ case configure(ConfigureAction)
}
struct CapturaURLDecoder {
-
+
static func decodeParams(url: URL) -> CapturaAction? {
- guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
- let params = components.queryItems else {
- return nil
- }
+ guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ let params = components.queryItems
+ else {
+ return nil
+ }
+
+ var paramsDict = [String: Any]()
+
+ params.forEach { item in
+ paramsDict[item.name] = item.value
+ }
+
+ guard let action = paramsDict["action"] as? String else {
+ return nil
+ }
- var paramsDict = [String: Any]()
+ switch action {
+ case "configure":
+ var fps = Int(paramsDict["fps"] as? String ?? "")
+ let backend = URL(string: paramsDict["backend"] as? String ?? "")
+ let keepLocalFiles = Bool(paramsDict["keep_local_files"] as? String ?? "")
+ let outputs = OutputFormatSetting(paramsDict["outputs"] as? String ?? "")
+ var backendOutput = OutputFormatSetting(paramsDict["backend_output"] as? String ?? "")
- params.forEach { item in
- paramsDict[item.name] = item.value
+ if fps != nil {
+ fps = min(10, max(4, fps!))
}
- guard let action = paramsDict["action"] as? String else {
- return nil
+ if backendOutput == .all {
+ backendOutput = .gifOnly
}
- switch action {
- case "configure":
- var fps = Int(paramsDict["fps"] as? String ?? "")
- let backend = URL(string: paramsDict["backend"] as? String ?? "")
- let keepLocalFiles = Bool(paramsDict["keep_local_files"] as? String ?? "")
- let outputs = OutputFormatSetting(paramsDict["outputs"] as? String ?? "")
- var backendOutput = OutputFormatSetting(paramsDict["backend_output"] as? String ?? "")
-
- if fps != nil {
- fps = min(10, max(4, fps!))
- }
-
- if backendOutput == .all {
- backendOutput = .gifOnly
- }
-
- return .configure(ConfigureAction(
+ return .configure(
+ ConfigureAction(
action: action,
fps: fps,
outputs: outputs,
keepLocalFiles: keepLocalFiles
))
- case "record":
- let x = Int(paramsDict["x"] as? String ?? "")
- let y = Int(paramsDict["y"] as? String ?? "")
- let width = Int(paramsDict["width"] as? String ?? "")
- let height = Int(paramsDict["height"] as? String ?? "")
- let preventResize = Bool(paramsDict["prevent_resize"] as? String ?? "")
- let preventMove = Bool(paramsDict["prevent_move"] as? String ?? "")
- var fps = Int(paramsDict["fps"] as? String ?? "")
- let backend = URL(string: paramsDict["backend"] as? String ?? "")
- let keepLocalFiles = Bool(paramsDict["keep_local_files"] as? String ?? "")
- let outputs = OutputFormatSetting(paramsDict["outputs"] as? String ?? "")
- var backendOutput = OutputFormatSetting(paramsDict["backend_output"] as? String ?? "")
- let autoStart = Bool(paramsDict["auto_start"] as? String ?? "")
- var maxLength = Int(paramsDict["max_length"] as? String ?? "")
-
- if fps != nil {
- fps = min(10, max(4, fps!))
- }
-
- if maxLength != nil {
- maxLength = min(300, max(1, fps!))
- }
-
- if backendOutput == .all {
- backendOutput = .gifOnly
- }
-
- var skipBackend = false
- if let backendString = paramsDict["backend"] as? String {
- if backendString == "" {
- skipBackend = true
- }
+ case "record":
+ let x = Int(paramsDict["x"] as? String ?? "")
+ let y = Int(paramsDict["y"] as? String ?? "")
+ let width = Int(paramsDict["width"] as? String ?? "")
+ let height = Int(paramsDict["height"] as? String ?? "")
+ let preventResize = Bool(paramsDict["prevent_resize"] as? String ?? "")
+ let preventMove = Bool(paramsDict["prevent_move"] as? String ?? "")
+ var fps = Int(paramsDict["fps"] as? String ?? "")
+ let backend = URL(string: paramsDict["backend"] as? String ?? "")
+ let keepLocalFiles = Bool(paramsDict["keep_local_files"] as? String ?? "")
+ let outputs = OutputFormatSetting(paramsDict["outputs"] as? String ?? "")
+ var backendOutput = OutputFormatSetting(paramsDict["backend_output"] as? String ?? "")
+ let autoStart = Bool(paramsDict["auto_start"] as? String ?? "")
+ var maxLength = Int(paramsDict["max_length"] as? String ?? "")
+
+ if fps != nil {
+ fps = min(10, max(4, fps!))
+ }
+
+ if maxLength != nil {
+ maxLength = min(300, max(1, fps!))
+ }
+
+ if backendOutput == .all {
+ backendOutput = .gifOnly
+ }
+
+ var skipBackend = false
+ if let backendString = paramsDict["backend"] as? String {
+ if backendString == "" {
+ skipBackend = true
}
-
- return .record(RecordAction(
+ }
+
+ return .record(
+ RecordAction(
action: action,
x: x,
y: y,
maxLength: maxLength
))
- default:
- return nil
- }
+ default:
+ return nil
+ }
}
}
+import CoreGraphics
+import SwiftUI
/*
Copyright (C) 2024 Rubén Beltrán del Río
along with this program. If not, see https://captura.tranquil.systems.
*/
import UniformTypeIdentifiers
-import SwiftUI
-import CoreGraphics
struct GifRenderer {
static func render(_ images: [CGImage], at fps: Int, to url: URL) async {
let framedelay = String(format: "%.3f", 1.0 / Double(fps))
- let fileProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]]
- let gifProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFUnclampedDelayTime as String: framedelay]]
+ let fileProperties = [
+ kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]
+ ]
+ let gifProperties = [
+ kCGImagePropertyGIFDictionary as String: [
+ kCGImagePropertyGIFUnclampedDelayTime as String: framedelay
+ ]
+ ]
let cfURL = url as CFURL
- if let destination = CGImageDestinationCreateWithURL(cfURL, UTType.gif.identifier as CFString, images.count, nil) {
+ if let destination = CGImageDestinationCreateWithURL(
+ cfURL, UTType.gif.identifier as CFString, images.count, nil)
+ {
CGImageDestinationSetProperties(destination, fileProperties as CFDictionary?)
for image in images {
CGImageDestinationAddImage(destination, image, gifProperties as CFDictionary?)
case gifOnly = 0
case mp4Only = 1
case all = 2
-
+
init?(_ string: String) {
- switch(string) {
+ switch string {
case "gif":
self = .gifOnly
case "mp4":
return nil
}
}
-
+
func shouldSaveGif() -> Bool {
return self == .gifOnly || self == .all
}
-
+
func shouldSaveMp4() -> Bool {
return self == .mp4Only || self == .all
}
-
+
func toString() -> String {
- switch(self) {
+ switch self {
case .gifOnly:
return "gif"
case .mp4Only:
import CoreData
struct PersistenceController {
- static let shared = PersistenceController()
+ static let shared = PersistenceController()
- static var preview: PersistenceController = {
- let result = PersistenceController(inMemory: true)
- return result
- }()
+ static var preview: PersistenceController = {
+ let result = PersistenceController(inMemory: true)
+ return result
+ }()
- let container: NSPersistentCloudKitContainer
+ let container: NSPersistentCloudKitContainer
+
+ init(inMemory: Bool = false) {
+ container = NSPersistentCloudKitContainer(name: "Captura")
- init(inMemory: Bool = false) {
- container = NSPersistentCloudKitContainer(name: "Captura")
-
#if DEBUG
- do {
+ do {
// Use the container to initialize the development schema.
try container.initializeCloudKitSchema(options: [])
- } catch {
+ } catch {
// Handle any errors.
- }
+ }
#endif
-
- if inMemory {
- container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
- }
- container.loadPersistentStores(completionHandler: { (storeDescription, error) in
- if let error = error as NSError? {
- // Replace this implementation with code to handle the error appropriately.
- // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
-
- /*
+
+ if inMemory {
+ container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
+ }
+ container.loadPersistentStores(completionHandler: { (storeDescription, error) in
+ if let error = error as NSError? {
+ // Replace this implementation with code to handle the error appropriately.
+ // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
+
+ /*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
- fatalError("Unresolved error \(error), \(error.userInfo)")
- }
- })
- container.viewContext.automaticallyMergesChangesFromParent = true
- }
+ fatalError("Unresolved error \(error), \(error.userInfo)")
+ }
+ })
+ container.viewContext.automaticallyMergesChangesFromParent = true
+ }
}
-import AppKit
import AVFoundation
+import AppKit
-class CapturaCaptureSession: AVCaptureSession, AVCaptureFileOutputRecordingDelegate, AVCaptureVideoDataOutputSampleBufferDelegate {
+class CapturaCaptureSession: AVCaptureSession, AVCaptureFileOutputRecordingDelegate,
+ AVCaptureVideoDataOutputSampleBufferDelegate
+{
let videoOutput = AVCaptureVideoDataOutput()
let movieFileOutput = AVCaptureMovieFileOutput()
var receivedFrames = false
-
+
init(_ screen: NSScreen, box: NSRect) {
super.init()
-
- let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
+
+ let displayId =
+ screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
let screenInput = AVCaptureScreenInput(displayID: displayId)
var croppingBox = NSOffsetRect(box, -screen.frame.origin.x, -screen.frame.origin.y)
if croppingBox.width.truncatingRemainder(dividingBy: 2) != 0 {
croppingBox.size.width -= 1
}
screenInput?.cropRect = croppingBox.insetBy(dx: 1, dy: 1)
-
+
if self.canAddInput(screenInput!) {
self.addInput(screenInput!)
}
-
- videoOutput.setSampleBufferDelegate(self, queue: Dispatch.DispatchQueue(label: "sample buffer delegate", attributes: []))
-
+
+ videoOutput.setSampleBufferDelegate(
+ self, queue: Dispatch.DispatchQueue(label: "sample buffer delegate", attributes: []))
+
if self.canAddOutput(videoOutput) {
self.addOutput(videoOutput)
}
-
+
if self.canAddOutput(movieFileOutput) {
self.addOutput(movieFileOutput)
}
}
-
+
func startRecording() {
receivedFrames = false
self.startRunning()
-
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if !self.receivedFrames {
NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil)
}
}
}
-
+
func startRecording(to url: URL) {
self.startRecording()
movieFileOutput.startRecording(to: url, recordingDelegate: self)
}
-
+
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate Implementation
-
- func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+
+ func captureOutput(
+ _ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
+ from connection: AVCaptureConnection
+ ) {
receivedFrames = true
-
+
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
- NotificationCenter.default.post(name: .receivedFrame, object: nil, userInfo: ["frame": imageBuffer])
+ NotificationCenter.default.post(
+ name: .receivedFrame, object: nil, userInfo: ["frame": imageBuffer])
}
-
+
// MARK: - AVCaptureFileOutputRecordingDelegate Implementation
-
- func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {}
+
+ func fileOutput(
+ _ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL,
+ from connections: [AVCaptureConnection], error: Error?
+ ) {}
}
let preventResize: Bool
let autoStart: Bool
let maxLength: Int
-
+
init(
frameRate: Int? = nil,
outputFormats: OutputFormatSetting? = nil,
autoStart = false
maxLength = 300
}
-
+
init(from action: RecordAction) {
self.frameRate = action.fps ?? CapturaSettings.frameRate
self.outputFormats = action.outputs ?? CapturaSettings.outputFormats
autoStart = action.autoStart ?? false
maxLength = action.maxLength ?? 300
}
-
+
var shouldSaveMp4: Bool {
outputFormats.shouldSaveMp4() || (shouldUseBackend && shouldUploadMp4)
}
-
+
var shouldSaveGif: Bool {
outputFormats.shouldSaveGif() || (shouldUseBackend && shouldUploadGif)
}
-
+
var shouldUploadGif: Bool {
backendFormat.shouldSaveGif()
}
-
+
var shouldUploadMp4: Bool {
backendFormat.shouldSaveMp4()
}
-
+
var shouldUseBackend: Bool {
backend != nil
}
struct CapturaShortcutsProvider: AppShortcutsProvider {
- static var appShortcuts: [AppShortcut] {
+ static var appShortcuts: [AppShortcut] {
- AppShortcut(intent: GetRemoteCaptures(), phrases: ["Get \(.applicationName) remote captures"])
+ AppShortcut(intent: GetRemoteCaptures(), phrases: ["Get \(.applicationName) remote captures"])
- }
+ }
}
import CoreData
struct GetRemoteCaptures: AppIntent {
- static var title: LocalizedStringResource = "Get remote captures"
-
- static var description =
- IntentDescription("Return a list of remote captures")
-
+ static var title: LocalizedStringResource = "Get remote captures"
+
+ static var description =
+ IntentDescription("Return a list of remote captures")
+
@Parameter(title: "Count") var count: Int?
-
+
static var parameterSummary: some ParameterSummary {
- Summary("Get \(\.$count) latest captures.")
+ Summary("Get \(\.$count) latest captures.")
}
-
+
func perform() async throws -> some IntentResult & ReturnsValue {
let viewContext = PersistenceController.shared.container.viewContext
let fetchRequest = NSFetchRequest<CapturaRemoteFile>(entityName: "CapturaRemoteFile")
fetchRequest.fetchLimit = min(10, max(1, count ?? 5))
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)]
-
+
let results = await viewContext.perform {
return try? viewContext.fetch(fetchRequest)
}
-
- let finalResults = results?.compactMap { URL(string: $0.url ?? "")} ?? []
+
+ let finalResults = results?.compactMap { URL(string: $0.url ?? "") } ?? []
return .result(value: finalResults)
}
}
import Cocoa
class HelpPopoverViewController: NSViewController {
-
- var labelString: String = "Captura"
- let textField = NSTextField()
-
- override func loadView() {
- self.view = NSView()
- self.view.frame = NSRect(x: 0, y: 0, width: 250, height: 40)
-
- textField.stringValue = labelString
- textField.font = NSFont(name: "Hiragino Mincho ProN", size: 12)
- textField.isEditable = false
- textField.isBezeled = false
- textField.isSelectable = false
- textField.backgroundColor = NSColor.clear
- textField.sizeToFit()
-
- let x = (view.frame.width - textField.frame.width) / 2
- let y = (view.frame.height - textField.frame.height) / 2
- textField.frame.origin = NSPoint(x: x, y: y)
-
- self.view.addSubview(textField)
- }
-
+
+ var labelString: String = "Captura"
+ let textField = NSTextField()
+
+ override func loadView() {
+ self.view = NSView()
+ self.view.frame = NSRect(x: 0, y: 0, width: 250, height: 40)
+
+ textField.stringValue = labelString
+ textField.font = NSFont(name: "Hiragino Mincho ProN", size: 12)
+ textField.isEditable = false
+ textField.isBezeled = false
+ textField.isSelectable = false
+ textField.backgroundColor = NSColor.clear
+ textField.sizeToFit()
+
+ let x = (view.frame.width - textField.frame.width) / 2
+ let y = (view.frame.height - textField.frame.height) / 2
+ textField.frame.origin = NSPoint(x: x, y: y)
+
+ self.view.addSubview(textField)
+ }
+
func updateLabel(_ newLabel: String) {
labelString = newLabel
textField.stringValue = labelString
import SwiftUI
struct PreferencesScreen: View {
- var body: some View {
- TabView {
- OutputSettings().tabItem {
- Label("Output", systemImage: "video.fill")
- }.padding(8.0).frame(minWidth: 300, minHeight: 130)
- AdvancedSettings().tabItem {
- Label("Advanced", systemImage: "gear")
- }.padding(8.0).frame(minWidth: 300, minHeight: 260)
- AboutSettings().tabItem {
- Label("About", systemImage: "questionmark.circle.fill")
- }.padding(8.0).frame(minWidth: 300, minHeight: 260)
- }.padding(16.0)
- }
+ var body: some View {
+ TabView {
+ OutputSettings().tabItem {
+ Label("Output", systemImage: "video.fill")
+ }.padding(8.0).frame(minWidth: 300, minHeight: 130)
+ AdvancedSettings().tabItem {
+ Label("Advanced", systemImage: "gear")
+ }.padding(8.0).frame(minWidth: 300, minHeight: 260)
+ AboutSettings().tabItem {
+ Label("About", systemImage: "questionmark.circle.fill")
+ }.padding(8.0).frame(minWidth: 300, minHeight: 260)
+ }.padding(16.0)
+ }
}
#Preview {
import SwiftUI
struct AboutSettings: View {
-
+
var appVersion: String {
- let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
- let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
- return "Version \(version) (\(build))"
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
+ let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
+ return "Version \(version) (\(build))"
}
-
- var imprint = (try? AttributedString(markdown: "Captura is open source. Help and more information available at [captura.tranquil.systems](https://captura.tranquil.systems)")) ?? ""
+
+ var imprint =
+ (try? AttributedString(
+ markdown:
+ "Captura is open source. Help and more information available at [captura.tranquil.systems](https://captura.tranquil.systems)"
+ )) ?? ""
var body: some View {
Form {
- VStack (alignment: .center) {
+ VStack(alignment: .center) {
Text("Captura").bold()
Text(appVersion).foregroundStyle(.secondary)
Spacer()
import SwiftUI
struct AdvancedSettings: View {
-
+
@AppStorage("backendUrl") var backendUrl: String = ""
@AppStorage("backendFormat") var backendFormat: OutputFormatSetting = .gifOnly
@AppStorage("keepFiles") var keepFiles = true
@AppStorage("allowURLAutomation") var allowURLAutomation = false
@State var showConfirmation = false
-
- private var anyState: String { "\(backendUrl), \(backendFormat), \(keepFiles), \(allowURLAutomation)" }
-
+
+ private var anyState: String {
+ "\(backendUrl), \(backendFormat), \(keepFiles), \(allowURLAutomation)"
+ }
+
var parsedBackendUrl: URL? {
URL(string: backendUrl)
}
-
+
var body: some View {
Form {
- VStack (alignment: .center) {
+ VStack(alignment: .center) {
Section {
- VStack (alignment: .center) {
+ VStack(alignment: .center) {
LabeledContent("Backend URL") {
TextField("", text: $backendUrl).font(.body)
}.font(.headline)
- .help("The Backend URL to use. If this is empty, no backend will be used and the options below won't have an effect.")
+ .help(
+ "The Backend URL to use. If this is empty, no backend will be used and the options below won't have an effect."
+ )
Picker(selection: $backendFormat, label: Text("Backend Format").font(.headline)) {
Text("GIF")
.tag(OutputFormatSetting.gifOnly)
}
.pickerStyle(.radioGroup)
.disabled(parsedBackendUrl == nil)
- .help("The format picked here will be generated regardless of what option you pick in the output settings. It doesn't prevent files from being rendered.")
+ .help(
+ "The format picked here will be generated regardless of what option you pick in the output settings. It doesn't prevent files from being rendered."
+ )
Toggle("Keep Local Files", isOn: $keepFiles)
.font(.headline)
.disabled(parsedBackendUrl == nil)
.padding(.vertical, 8.0)
- .help("If this is off, locally generated recordings will be deleted immediately after a successful upload.")
+ .help(
+ "If this is off, locally generated recordings will be deleted immediately after a successful upload."
+ )
HStack {
- Text("These settings can break things! Please make sure you understand how to use them before enabling.")
- .lineLimit(3...10)
+ Text(
+ "These settings can break things! Please make sure you understand how to use them before enabling."
+ )
+ .lineLimit(3...10)
Link(destination: URL(string: "https://captura.tranquil.systems")!) {
Image(systemName: "info.circle")
}.buttonStyle(.borderless)
Toggle("Allow URL Based Automation", isOn: $allowURLAutomation)
.font(.headline)
.padding(.vertical, 8.0)
- .help("If this is on, the app can be controlled remotely using the captura: URL scheme.")
- .confirmationDialog("This may be dangerous and can allow websites to remotely record your computer.", isPresented: $showConfirmation, actions: {
- Button("I Understand The Risk", role: .destructive) {
- showConfirmation = false
- }
- Button("Cancel", role: .cancel) {
- showConfirmation = false
- allowURLAutomation = false
- }
- })
- .onChange(of: allowURLAutomation, perform: { newValue in
- if newValue {
- showConfirmation = true
+ .help(
+ "If this is on, the app can be controlled remotely using the captura: URL scheme."
+ )
+ .confirmationDialog(
+ "This may be dangerous and can allow websites to remotely record your computer.",
+ isPresented: $showConfirmation,
+ actions: {
+ Button("I Understand The Risk", role: .destructive) {
+ showConfirmation = false
+ }
+ Button("Cancel", role: .cancel) {
+ showConfirmation = false
+ allowURLAutomation = false
+ }
}
- })
+ )
+ .onChange(
+ of: allowURLAutomation,
+ perform: { newValue in
+ if newValue {
+ showConfirmation = true
+ }
+ })
}
Spacer()
}
import SwiftUI
struct OutputSettings: View {
-
+
@AppStorage("outputFormats") var outputFormats: OutputFormatSetting = .all
@AppStorage("frameRate") var frameRate = 10.0
-
+
private var anyState: String { "\(outputFormats), \(frameRate)" }
-
+
var body: some View {
Form {
- VStack (alignment: .center) {
+ VStack(alignment: .center) {
LabeledContent("GIF Framerate") {
- Slider(value: $frameRate, in: 4...10, step: 1) {
+ Slider(value: $frameRate, in: 4...10, step: 1) {
Text("\(Int(frameRate))").font(.body).frame(width: 24)
} minimumValueLabel: {
Text("4")
.tag(OutputFormatSetting.gifOnly)
.padding(.horizontal, 4.0)
.padding(.vertical, 2.0)
-
+
Text("Only MP4")
.tag(OutputFormatSetting.mp4Only)
.padding(.horizontal, 4.0)
along with this program. If not, see https://captura.tranquil.systems.
*/
import Cocoa
-import SwiftUI
-
import Foundation
+import SwiftUI
class PreferencesWindow: NSWindow {
-
+
init() {
super.init(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 600),
import Combine
class RecordingWindow: NSWindow {
-
+
var pixelDensity: CGFloat {
self.screen?.backingScaleFactor ?? 1.0
}
-
+
var recordingContentView: RecordingContentView {
self.contentView as! RecordingContentView
}
-
+
init(_ configuration: CaptureSessionConfiguration, _ button: NSRect?) {
-
+
let boundingBox = NSScreen.screenWithMouse?.frame ?? NSZeroRect
-
+
super.init(
contentRect: boundingBox,
styleMask: [.borderless],
backing: .buffered,
defer: false)
-
self.isReleasedWhenClosed = false
self.collectionBehavior = [.canJoinAllSpaces]
self.isMovableByWindowBackground = false
self.titlebarAppearsTransparent = true
self.setFrame(boundingBox, display: true)
self.titleVisibility = .hidden
- let recordingView = RecordingContentView(configuration, frame: boundingBox, button: button ?? NSZeroRect)
+ let recordingView = RecordingContentView(
+ configuration, frame: boundingBox, button: button ?? NSZeroRect)
recordingView.frame = boundingBox
self.contentView = recordingView
self.backgroundColor = NSColor(white: 1.0, alpha: 0.001)
self.isOpaque = false
self.hasShadow = false
}
-
+
// MARK: - Window Behavior Overrides
-
+
override func resetCursorRects() {
super.resetCursorRects()
let cursor = NSCursor.crosshair
self.contentView?.addCursorRect(self.contentView!.bounds, cursor: cursor)
}
-
+
override var canBecomeKey: Bool {
return true
}
-
+
override var canBecomeMain: Bool {
return true
}
-
+
override func resignMain() {
super.resignMain()
if (self.contentView as? RecordingContentView)?.state != .recording {
self.ignoresMouseEvents = false
}
}
-
+
override func becomeMain() {
super.becomeMain()
if (self.contentView as? RecordingContentView)?.state != .recording {
}
enum RecordingWindowState {
- case passthrough, idle, drawing, moving, resizing, recording;
+ case passthrough, idle, drawing, moving, resizing, recording
}
class RecordingContentView: NSView {
-
+
init(_ configuration: CaptureSessionConfiguration, frame: NSRect, button: NSRect) {
self.buttonSize = button.size
var buttonOffset = NSPoint()
preventResize = configuration.preventResize
preventMove = configuration.preventMove
autoStart = configuration.autoStart
-
+
self.bounds = frame
- self.button = NSRect(x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width, height: buttonSize.height)
-
- if configuration.x != nil || configuration.y != nil || configuration.width != nil || configuration.height != nil {
+ self.button = NSRect(
+ x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
+ height: buttonSize.height)
+
+ if configuration.x != nil || configuration.y != nil || configuration.width != nil
+ || configuration.height != nil
+ {
box = NSRect(
x: configuration.x ?? Int(frame.width / 2.0),
y: configuration.y ?? Int(frame.height / 2.0),
height: configuration.height ?? 400
)
}
-
+
if autoStart {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
}
}
}
-
+
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
-
+
private let buttonSize: NSSize
private let buttonOffset: NSPoint
public var button: NSRect? = nil
private var preventResize = false
private var preventMove = false
private var autoStart = false
-
+
private var resizeBox: NSRect? {
if let box {
return NSRect(x: box.maxX - 5, y: box.minY - 5, width: 10, height: 10)
}
return nil
}
-
+
private var shouldPassthrough: Bool {
state == .recording || state == .passthrough
}
-
+
// MARK: - State changing API
-
+
public func startRecording() {
state = .recording
window?.ignoresMouseEvents = true
}
-
+
public func stopRecording() {
-
+
}
-
+
public func reset() {
state = .idle
window?.ignoresMouseEvents = false
}
-
+
public func startPassthrough() {
state = .passthrough
window?.ignoresMouseEvents = true
}
-
+
public func stopPassthrough() {
state = .idle
window?.ignoresMouseEvents = false
}
-
+
// MARK: - View Behavior Overrides
-
+
override func updateTrackingAreas() {
super.updateTrackingAreas()
-
+
for trackingArea in self.trackingAreas {
self.removeTrackingArea(trackingArea)
}
- let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .cursorUpdate, .mouseMoved]
- let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
+ let options: NSTrackingArea.Options = [
+ .mouseEnteredAndExited, .activeInKeyWindow, .cursorUpdate, .mouseMoved,
+ ]
+ let trackingArea = NSTrackingArea(
+ rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(trackingArea)
}
-
+
override func mouseExited(with event: NSEvent) {
if state == .idle && box == nil {
self.moveWindow()
}
}
-
+
override func mouseMoved(with event: NSEvent) {
-
+
self.mouseLocation = self.convert(event.locationInWindow, from: nil)
-
+
if shouldPassthrough {
NSCursor.arrow.set()
} else {
self.setNeedsDisplay(self.bounds)
}
-
+
override func mouseDragged(with event: NSEvent) {
self.mouseLocation = self.convert(event.locationInWindow, from: nil)
if state == .drawing {
height: round(abs(mouseLocation.y - origin.y))
)
}
-
+
if box != nil {
if state == .moving {
NSCursor.closedHand.set()
x: self.boxOrigin.x - self.origin.x + self.mouseLocation.x,
y: self.boxOrigin.y - self.origin.y + self.mouseLocation.y)
}
-
+
if state == .resizing {
box = NSRect(
x: round(min(origin.x, mouseLocation.x)),
}
self.setNeedsDisplay(self.bounds)
}
-
+
override func cursorUpdate(with event: NSEvent) {
NSCursor.crosshair.set()
}
override func hitTest(_ point: NSPoint) -> NSView? {
return shouldPassthrough ? nil : self
}
-
+
override var acceptsFirstResponder: Bool {
return true
}
override func mouseDown(with event: NSEvent) {
self.origin = self.convert(event.locationInWindow, from: nil)
if let box {
-
+
if let button {
if button.contains(origin) {
NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil)
return
}
}
-
+
if resizeBox!.contains(origin) && !preventResize {
self.origin = NSPoint(x: box.minX, y: box.maxY)
state = .resizing
return
}
}
-
+
if preventResize || preventMove {
return
}
-
+
state = .drawing
}
state = .idle
}
}
-
+
override func keyDown(with event: NSEvent) {
switch event.keyCode {
- case 53: // Escape key
- NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
- default:
- super.keyDown(with: event)
+ case 53: // Escape key
+ NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil)
+ default:
+ super.keyDown(with: event)
}
}
-
+
override func flagsChanged(with event: NSEvent) {
if state == .idle {
if event.modifierFlags.contains(.shift) {
}
}
}
-
+
override func draw(_ dirtyRect: NSRect) {
if shouldPassthrough {
NSColor.clear.setFill()
NSColor(white: 1.0, alpha: 0.001).setFill()
}
dirtyRect.fill()
-
+
let dashLength: CGFloat = 5.0
let lineWidth = 0.5
-// Uncomment below to debug button placement visually
-// if let button {
-// let buttonPath = NSBezierPath()
-// buttonPath.move(to: NSPoint(x: button.minX, y: button.minY))
-// buttonPath.line(to: NSPoint(x: button.maxX, y: button.minY))
-// buttonPath.line(to: NSPoint(x: button.maxX, y: button.maxY))
-// buttonPath.line(to: NSPoint(x: button.minX, y: button.maxY))
-// buttonPath.line(to: NSPoint(x: button.minX, y: button.minY))
-// NSColor(red: 1, green: 0, blue: 1, alpha: 1).setFill()
-// buttonPath.fill()
-// }
+ // Uncomment below to debug button placement visually
+ // if let button {
+ // let buttonPath = NSBezierPath()
+ // buttonPath.move(to: NSPoint(x: button.minX, y: button.minY))
+ // buttonPath.line(to: NSPoint(x: button.maxX, y: button.minY))
+ // buttonPath.line(to: NSPoint(x: button.maxX, y: button.maxY))
+ // buttonPath.line(to: NSPoint(x: button.minX, y: button.maxY))
+ // buttonPath.line(to: NSPoint(x: button.minX, y: button.minY))
+ // NSColor(red: 1, green: 0, blue: 1, alpha: 1).setFill()
+ // buttonPath.fill()
+ // }
if state == .idle && box == nil {
let blackLine = NSBezierPath()
blackLine.lineWidth = lineWidth
blackLine.setLineDash([dashLength, dashLength], count: 2, phase: 0)
-
+
// Vertical line (Black)
blackLine.move(to: NSPoint(x: self.mouseLocation.x, y: NSMinY(self.bounds)))
blackLine.line(to: NSPoint(x: self.mouseLocation.x, y: NSMaxY(self.bounds)))
-
+
// Horizontal line (Black)
blackLine.move(to: NSPoint(x: NSMinX(self.bounds), y: self.mouseLocation.y))
blackLine.line(to: NSPoint(x: NSMaxX(self.bounds), y: self.mouseLocation.y))
-
+
NSColor.black.setStroke()
blackLine.stroke()
-
+
let whiteLine = NSBezierPath()
whiteLine.lineWidth = lineWidth
whiteLine.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
-
+
// Vertical line (White)
whiteLine.move(to: NSPoint(x: self.mouseLocation.x, y: NSMinY(self.bounds)))
whiteLine.line(to: NSPoint(x: self.mouseLocation.x, y: NSMaxY(self.bounds)))
-
+
// Horizontal line (White)
whiteLine.move(to: NSPoint(x: NSMinX(self.bounds), y: self.mouseLocation.y))
whiteLine.line(to: NSPoint(x: NSMaxX(self.bounds), y: self.mouseLocation.y))
-
+
NSColor.white.setStroke()
whiteLine.stroke()
}
-
+
if let box {
let blackBox = NSBezierPath()
blackBox.lineWidth = lineWidth
blackBox.line(to: NSPoint(x: box.minX, y: box.minY))
NSColor.black.setStroke()
blackBox.stroke()
-
+
let whiteBox = NSBezierPath()
whiteBox.lineWidth = lineWidth
whiteBox.setLineDash([dashLength, dashLength], count: 2, phase: dashLength)
whiteBox.line(to: NSPoint(x: box.minX, y: box.minY))
NSColor.white.setStroke()
whiteBox.stroke()
-
+
if state == .recording {
return
}
-
+
if let resizeBox {
let clearBox = NSBezierPath()
clearBox.move(to: NSPoint(x: resizeBox.minX, y: resizeBox.minY))
NSColor(white: 0, alpha: 0.2).setFill()
clearBox.fill()
}
-
+
if state == .moving {
let string = "\(Int(box.minX)), \(Int(box.maxY))" as NSString
- drawText(string, NSPoint(
- x: box.minX,
- y: box.maxY
- ), true)
+ drawText(
+ string,
+ NSPoint(
+ x: box.minX,
+ y: box.maxY
+ ), true)
}
-
+
if state == .resizing {
let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
drawText(string, mouseLocation)
}
-
+
if box.contains(mouseLocation) && state != .resizing {
- return;
+ return
}
}
-
+
// Draw text
let string = "\(Int(mouseLocation.x)), \(Int(mouseLocation.y))" as NSString
drawText(string, mouseLocation)
}
-
+
// MARK: - Utilities
-
+
private func drawText(_ text: NSString, _ location: NSPoint, _ isBottomRight: Bool = false) {
-
+
let textAttributes = [
- NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12) ?? NSFont.systemFont(ofSize: 12),
+ NSAttributedString.Key.font: NSFont(name: "Hiragino Mincho ProN", size: 12)
+ ?? NSFont.systemFont(ofSize: 12),
NSAttributedString.Key.foregroundColor: NSColor.white,
]
let offset = NSPoint(x: 10, y: 10)
let padding = NSPoint(x: 5, y: 2)
let size = text.size(withAttributes: textAttributes)
- var rect = NSRect(x: location.x + offset.x, y: location.y + offset.y, width: size.width + 2 * padding.x, height: size.height + 2 * padding.y)
- var textRect = NSRect(x: location.x + offset.x + padding.x, y: location.y + offset.y + padding.y, width: size.width, height: size.height)
-
- if (isBottomRight) {
+ var rect = NSRect(
+ x: location.x + offset.x, y: location.y + offset.y, width: size.width + 2 * padding.x,
+ height: size.height + 2 * padding.y)
+ var textRect = NSRect(
+ x: location.x + offset.x + padding.x, y: location.y + offset.y + padding.y, width: size.width,
+ height: size.height)
+
+ if isBottomRight {
rect = rect.offsetBy(dx: -size.width - 2 * offset.x, dy: 0)
textRect = textRect.offsetBy(dx: -size.width - 2 * offset.x, dy: 0)
}
text.draw(in: textRect, withAttributes: textAttributes)
}
-
+
private func moveWindow() {
let screen = NSScreen.screenWithMouse
if let currentScreen = self.window?.screen {
self.frame = CGRect(origin: NSZeroPoint, size: frame.size)
self.bounds = CGRect(origin: NSZeroPoint, size: frame.size)
self.updateTrackingAreas()
-
+
if let window = self.window {
self.bounds = frame
- self.button = NSRect(x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width, height: buttonSize.height)
+ self.button = NSRect(
+ x: frame.maxX - buttonOffset.x, y: frame.maxY - buttonOffset.y, width: buttonSize.width,
+ height: buttonSize.height)
window.setFrame(frame, display: true, animate: false)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
@objc(ConfigureCommand)
class ConfigureCommand: NSScriptCommand {
- override func performDefaultImplementation() -> Any? {
-
- let args = self.directParameter as? [String: Any] ?? [:]
-
- // Here you can extract the parameters from the args dictionary and configure your settings
- let fps = args["fps"] as? Int
- let outputs = OutputFormatSetting(args["outputs"] as? String ?? "")
- let backend = URL(string: args["backend"] as? String ?? "")
- let backendOutput = OutputFormatSetting(args["backend_output"] as? String ?? "")
- let keepLocalFiles = args["keep_local_files"] as? Bool
-
- let config = ConfigureAction(
- action: "configure",
- fps: fps,
- outputs: outputs,
- backend: backend,
- backendOutput: backendOutput,
- keepLocalFiles: keepLocalFiles
- )
-
- NotificationCenter.default.post(name: .setConfiguration, object: nil, userInfo: [
+ override func performDefaultImplementation() -> Any? {
+
+ let args = self.directParameter as? [String: Any] ?? [:]
+
+ // Here you can extract the parameters from the args dictionary and configure your settings
+ let fps = args["fps"] as? Int
+ let outputs = OutputFormatSetting(args["outputs"] as? String ?? "")
+ let backend = URL(string: args["backend"] as? String ?? "")
+ let backendOutput = OutputFormatSetting(args["backend_output"] as? String ?? "")
+ let keepLocalFiles = args["keep_local_files"] as? Bool
+
+ let config = ConfigureAction(
+ action: "configure",
+ fps: fps,
+ outputs: outputs,
+ backend: backend,
+ backendOutput: backendOutput,
+ keepLocalFiles: keepLocalFiles
+ )
+
+ NotificationCenter.default.post(
+ name: .setConfiguration, object: nil,
+ userInfo: [
"config": config
])
- NotificationCenter.default.post(name: .reloadConfiguration, object: nil, userInfo: nil)
+ NotificationCenter.default.post(name: .reloadConfiguration, object: nil, userInfo: nil)
- // Return a result if needed
- return nil
- }
+ // Return a result if needed
+ return nil
+ }
}
@objc(RecordCommand)
class RecordCommand: NSScriptCommand {
- override func performDefaultImplementation() -> Any? {
-
- let args = self.directParameter as? [String: Any] ?? [:]
-
- // Here you can extract the parameters from the args dictionary and configure your settings
- let x = args["x"] as? Int
- let y = args["y"] as? Int
- let width = args["width"] as? Int
- let height = args["height"] as? Int
- let preventResize = args["prevent_resize"] as? Bool
- let preventMove = args["prevent_move"] as? Bool
- let fps = args["fps"] as? Int
- let outputs = OutputFormatSetting(args["outputs"] as? String ?? "")
- let backend = URL(string: args["backend"] as? String ?? "")
- let backendOutput = OutputFormatSetting(args["backend_output"] as? String ?? "")
- let keepLocalFiles = args["keep_local_files"] as? Bool
- let autoStart = args["auto_start"] as? Bool
- let maxLength = args["max_length"] as? Int
-
- var skipBackend = false
- if let backendString = args["backend"] as? String {
- if backendString == "" {
- skipBackend = true
- }
+ override func performDefaultImplementation() -> Any? {
+
+ let args = self.directParameter as? [String: Any] ?? [:]
+
+ // Here you can extract the parameters from the args dictionary and configure your settings
+ let x = args["x"] as? Int
+ let y = args["y"] as? Int
+ let width = args["width"] as? Int
+ let height = args["height"] as? Int
+ let preventResize = args["prevent_resize"] as? Bool
+ let preventMove = args["prevent_move"] as? Bool
+ let fps = args["fps"] as? Int
+ let outputs = OutputFormatSetting(args["outputs"] as? String ?? "")
+ let backend = URL(string: args["backend"] as? String ?? "")
+ let backendOutput = OutputFormatSetting(args["backend_output"] as? String ?? "")
+ let keepLocalFiles = args["keep_local_files"] as? Bool
+ let autoStart = args["auto_start"] as? Bool
+ let maxLength = args["max_length"] as? Int
+
+ var skipBackend = false
+ if let backendString = args["backend"] as? String {
+ if backendString == "" {
+ skipBackend = true
}
-
- let config = RecordAction(
- action: "record",
- x: x,
- y: y,
- width: width,
- height: height,
- preventResize: preventResize,
- preventMove: preventMove,
- fps: fps,
- outputs: outputs,
- backend: backend,
- backendOutput: backendOutput,
- keepLocalFiles: keepLocalFiles,
- autoStart: autoStart,
- skipBackend: skipBackend,
- maxLength: maxLength
- )
-
- NotificationCenter.default.post(name: .setCaptureSessionConfiguration, object: nil, userInfo: [
+ }
+
+ let config = RecordAction(
+ action: "record",
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ preventResize: preventResize,
+ preventMove: preventMove,
+ fps: fps,
+ outputs: outputs,
+ backend: backend,
+ backendOutput: backendOutput,
+ keepLocalFiles: keepLocalFiles,
+ autoStart: autoStart,
+ skipBackend: skipBackend,
+ maxLength: maxLength
+ )
+
+ NotificationCenter.default.post(
+ name: .setCaptureSessionConfiguration, object: nil,
+ userInfo: [
"config": config
])
- NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
+ NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
- // Return a result if needed
- return nil
- }
+ // Return a result if needed
+ return nil
+ }
}
CapturaSettings.outputFormats = OutputFormatSetting(newValue) ?? .gifOnly
}
}
-
+
@objc dynamic var backend: String {
get {
CapturaSettings.backend?.absoluteString ?? ""
CapturaSettings.backend = URL(string: newValue)
}
}
-
+
@objc dynamic var backend_output: String {
get {
CapturaSettings.backendFormat.toString()
CapturaSettings.backendFormat = OutputFormatSetting(newValue) ?? .gifOnly
}
}
-
+
@objc dynamic var keep_local_files: Bool {
get {
CapturaSettings.shouldKeepLocalFiles
*/
import XCTest
+
@testable import Captura
final class CapturaTests: XCTestCase {
- override func setUpWithError() throws {
- // Put setup code here. This method is called before the invocation of each test method in the class.
- }
-
- override func tearDownWithError() throws {
- // Put teardown code here. This method is called after the invocation of each test method in the class.
- }
-
- func testExample() throws {
- // This is an example of a functional test case.
- // Use XCTAssert and related functions to verify your tests produce the correct results.
- // Any test you write for XCTest can be annotated as throws and async.
- // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
- // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
- }
-
- func testPerformanceExample() throws {
- // This is an example of a performance test case.
- self.measure {
- // Put the code you want to measure the time of here.
- }
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
+ }
+
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
+
+ func testExample() throws {
+ // This is an example of a functional test case.
+ // Use XCTAssert and related functions to verify your tests produce the correct results.
+ // Any test you write for XCTest can be annotated as throws and async.
+ // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error.
+ // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards.
+ }
+
+ func testPerformanceExample() throws {
+ // This is an example of a performance test case.
+ self.measure {
+ // Put the code you want to measure the time of here.
}
+ }
}
final class CapturaUITests: XCTestCase {
- override func setUpWithError() throws {
- // Put setup code here. This method is called before the invocation of each test method in the class.
+ override func setUpWithError() throws {
+ // Put setup code here. This method is called before the invocation of each test method in the class.
- // In UI tests it is usually best to stop immediately when a failure occurs.
- continueAfterFailure = false
+ // In UI tests it is usually best to stop immediately when a failure occurs.
+ continueAfterFailure = false
- // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
- }
+ // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
+ }
- override func tearDownWithError() throws {
- // Put teardown code here. This method is called after the invocation of each test method in the class.
- }
+ override func tearDownWithError() throws {
+ // Put teardown code here. This method is called after the invocation of each test method in the class.
+ }
- func testExample() throws {
- // UI tests must launch the application that they test.
- let app = XCUIApplication()
- app.launch()
+ func testExample() throws {
+ // UI tests must launch the application that they test.
+ let app = XCUIApplication()
+ app.launch()
- // Use XCTAssert and related functions to verify your tests produce the correct results.
- }
+ // Use XCTAssert and related functions to verify your tests produce the correct results.
+ }
- func testLaunchPerformance() throws {
- if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
- // This measures how long it takes to launch your application.
- measure(metrics: [XCTApplicationLaunchMetric()]) {
- XCUIApplication().launch()
- }
- }
+ func testLaunchPerformance() throws {
+ if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
+ // This measures how long it takes to launch your application.
+ measure(metrics: [XCTApplicationLaunchMetric()]) {
+ XCUIApplication().launch()
+ }
}
+ }
}
final class CapturaUITestsLaunchTests: XCTestCase {
- override class var runsForEachTargetApplicationUIConfiguration: Bool {
- true
- }
-
- override func setUpWithError() throws {
- continueAfterFailure = false
- }
-
- func testLaunch() throws {
- let app = XCUIApplication()
- app.launch()
-
- // Insert steps here to perform after app launch but before taking a screenshot,
- // such as logging into a test account or navigating somewhere in the app
-
- let attachment = XCTAttachment(screenshot: app.screenshot())
- attachment.name = "Launch Screen"
- attachment.lifetime = .keepAlways
- add(attachment)
- }
+ override class var runsForEachTargetApplicationUIConfiguration: Bool {
+ true
+ }
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ }
+
+ func testLaunch() throws {
+ let app = XCUIApplication()
+ app.launch()
+
+ // Insert steps here to perform after app launch but before taking a screenshot,
+ // such as logging into a test account or navigating somewhere in the app
+
+ let attachment = XCTAttachment(screenshot: app.screenshot())
+ attachment.name = "Launch Screen"
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
}
prepare:
mkdir -p $(build_directory)
-.PHONY: package prepare archive generate_appcast package distribute
+format:
+ swift format -i -r .
+
+lint:
+ swift format lint -r .
+
+.PHONY: package prepare archive generate_appcast package distribute format lint