From c9b9e1d654ea697afad9f6427d94623bfdf55cce Mon Sep 17 00:00:00 2001 From: Ruben Beltran del Rio Date: Fri, 28 Jul 2023 13:48:53 +0200 Subject: Add clipboard --- Captura.xcodeproj/project.pbxproj | 32 +- Captura/CapturaApp.swift | 316 ++++++++++++++++++++ .../Core Extensions/CVImageBuffer+cgImage.swift | 11 + .../Core Extensions/Notification+AppEvents.swift | 2 + Captura/Data/CapturaRemoteFile.swift | 13 + Captura/Data/CapturaSettings.swift | 29 ++ Captura/Data/CaptureState.swift | 7 - Captura/Domain/CapturaCaptureSession.swift | 60 ++++ Captura/Domain/CaptureState.swift | 8 + Captura/Item.swift | 18 -- .../Presentation/Screens/PreferencesScreen.swift | 6 +- .../Presentation/Settings/AdvancedSettings.swift | 32 +- Captura/Presentation/Settings/OutputSettings.swift | 5 +- Captura/Presentation/Windows/CapturaApp.swift | 329 --------------------- Captura/Presentation/Windows/RecordingWindow.swift | 4 + README.md | 31 ++ 16 files changed, 530 insertions(+), 373 deletions(-) create mode 100644 Captura/CapturaApp.swift create mode 100644 Captura/Core Extensions/CVImageBuffer+cgImage.swift create mode 100644 Captura/Data/CapturaRemoteFile.swift create mode 100644 Captura/Data/CapturaSettings.swift delete mode 100644 Captura/Data/CaptureState.swift create mode 100644 Captura/Domain/CapturaCaptureSession.swift create mode 100644 Captura/Domain/CaptureState.swift delete mode 100644 Captura/Item.swift delete mode 100644 Captura/Presentation/Windows/CapturaApp.swift create mode 100644 README.md diff --git a/Captura.xcodeproj/project.pbxproj b/Captura.xcodeproj/project.pbxproj index a506dc5..3238749 100644 --- a/Captura.xcodeproj/project.pbxproj +++ b/Captura.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B272A739871009F6462 /* CGImage+resize.swift */; }; B5278B2A2A73992D009F6462 /* GifRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B292A73992D009F6462 /* GifRenderer.swift */; }; B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B2B2A739B3A009F6462 /* CapturaFile.swift */; }; + B5278B312A73AEAE009F6462 /* CVImageBuffer+cgImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */; }; + B5278B362A73B3AA009F6462 /* CapturaCaptureSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */; }; + B5278B382A73D1EE009F6462 /* CapturaSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5278B372A73D1EE009F6462 /* CapturaSettings.swift */; }; B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */; }; B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */; }; B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */; }; @@ -22,7 +25,7 @@ B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */; }; B5F915562A6EF80E007ECE8E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915552A6EF80E007ECE8E /* Assets.xcassets */; }; B5F915592A6EF80E007ECE8E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */; }; - B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* Item.swift */; }; + B5F9155B2A6EF80E007ECE8E /* CapturaRemoteFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */; }; B5F915662A6EF80E007ECE8E /* CapturaTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915652A6EF80E007ECE8E /* CapturaTests.swift */; }; B5F915702A6EF80E007ECE8E /* CapturaUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F9156F2A6EF80E007ECE8E /* CapturaUITests.swift */; }; B5F915722A6EF80E007ECE8E /* CapturaUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5F915712A6EF80E007ECE8E /* CapturaUITestsLaunchTests.swift */; }; @@ -54,6 +57,9 @@ B5278B272A739871009F6462 /* CGImage+resize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+resize.swift"; sourceTree = ""; }; B5278B292A73992D009F6462 /* GifRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifRenderer.swift; sourceTree = ""; }; B5278B2B2A739B3A009F6462 /* CapturaFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaFile.swift; sourceTree = ""; }; + B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CVImageBuffer+cgImage.swift"; sourceTree = ""; }; + B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaCaptureSession.swift; sourceTree = ""; }; + B5278B372A73D1EE009F6462 /* CapturaSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaSettings.swift; sourceTree = ""; }; B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+AppEvents.swift"; sourceTree = ""; }; B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingWindow.swift; sourceTree = ""; }; B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaptureState.swift; sourceTree = ""; }; @@ -62,7 +68,7 @@ B5F915532A6EF80D007ECE8E /* PreferencesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesScreen.swift; sourceTree = ""; }; B5F915552A6EF80E007ECE8E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B5F915582A6EF80E007ECE8E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - B5F9155A2A6EF80E007ECE8E /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaRemoteFile.swift; sourceTree = ""; }; B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Captura.entitlements; sourceTree = ""; }; B5F915612A6EF80E007ECE8E /* CapturaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CapturaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B5F915652A6EF80E007ECE8E /* CapturaTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturaTests.swift; sourceTree = ""; }; @@ -118,7 +124,6 @@ B5278B1A2A71BD1F009F6462 /* Windows */ = { isa = PBXGroup; children = ( - B5F915512A6EF80D007ECE8E /* CapturaApp.swift */, B55DDFCD2A6F069D001A5E76 /* RecordingWindow.swift */, B5278B222A71C140009F6462 /* PreferencesWindow.swift */, ); @@ -136,10 +141,11 @@ B5278B1C2A71BD3C009F6462 /* Data */ = { isa = PBXGroup; children = ( - B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */, B5278B202A71BFC3009F6462 /* SettingsStructs.swift */, B5278B292A73992D009F6462 /* GifRenderer.swift */, B5278B2B2A739B3A009F6462 /* CapturaFile.swift */, + B5F9155A2A6EF80E007ECE8E /* CapturaRemoteFile.swift */, + B5278B372A73D1EE009F6462 /* CapturaSettings.swift */, ); path = Data; sourceTree = ""; @@ -158,10 +164,20 @@ children = ( B55DDFCB2A6F0253001A5E76 /* Notification+AppEvents.swift */, B5278B272A739871009F6462 /* CGImage+resize.swift */, + B5278B302A73AEAE009F6462 /* CVImageBuffer+cgImage.swift */, ); path = "Core Extensions"; sourceTree = ""; }; + B5278B322A73AFEC009F6462 /* Domain */ = { + isa = PBXGroup; + children = ( + B56C70CC2A6EFDF4009B97EB /* CaptureState.swift */, + B5278B352A73B3AA009F6462 /* CapturaCaptureSession.swift */, + ); + path = Domain; + sourceTree = ""; + }; B5F915452A6EF80D007ECE8E = { isa = PBXGroup; children = ( @@ -185,11 +201,12 @@ B5F915502A6EF80D007ECE8E /* Captura */ = { isa = PBXGroup; children = ( + B5F915512A6EF80D007ECE8E /* CapturaApp.swift */, B5278B262A739862009F6462 /* Core Extensions */, B5278B1C2A71BD3C009F6462 /* Data */, + B5278B322A73AFEC009F6462 /* Domain */, B5278B182A71BD10009F6462 /* Presentation */, B5F915552A6EF80E007ECE8E /* Assets.xcassets */, - B5F9155A2A6EF80E007ECE8E /* Item.swift */, B5F9155C2A6EF80E007ECE8E /* Captura.entitlements */, B5F915572A6EF80E007ECE8E /* Preview Content */, ); @@ -352,6 +369,7 @@ buildActionMask = 2147483647; files = ( B5F915542A6EF80D007ECE8E /* PreferencesScreen.swift in Sources */, + B5278B382A73D1EE009F6462 /* CapturaSettings.swift in Sources */, B5278B282A739871009F6462 /* CGImage+resize.swift in Sources */, B5278B2C2A739B3A009F6462 /* CapturaFile.swift in Sources */, B5278B1F2A71BD9B009F6462 /* OutputSettings.swift in Sources */, @@ -359,11 +377,13 @@ B5278B212A71BFC3009F6462 /* SettingsStructs.swift in Sources */, B55DDFCE2A6F069D001A5E76 /* RecordingWindow.swift in Sources */, B55DDFCC2A6F0253001A5E76 /* Notification+AppEvents.swift in Sources */, - B5F9155B2A6EF80E007ECE8E /* Item.swift in Sources */, + B5F9155B2A6EF80E007ECE8E /* CapturaRemoteFile.swift in Sources */, B5278B232A71C140009F6462 /* PreferencesWindow.swift in Sources */, B5278B172A71528F009F6462 /* HelpPopoverViewController.swift in Sources */, B56C70CD2A6EFDF4009B97EB /* CaptureState.swift in Sources */, + B5278B312A73AEAE009F6462 /* CVImageBuffer+cgImage.swift in Sources */, B5278B252A71CA80009F6462 /* AdvancedSettings.swift in Sources */, + B5278B362A73B3AA009F6462 /* CapturaCaptureSession.swift in Sources */, B5F915522A6EF80D007ECE8E /* CapturaApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Captura/CapturaApp.swift b/Captura/CapturaApp.swift new file mode 100644 index 0000000..4f1ceff --- /dev/null +++ b/Captura/CapturaApp.swift @@ -0,0 +1,316 @@ +import SwiftUI +import SwiftData +import Cocoa +import Combine +import AVFoundation + +@main +struct CapturaApp: App { + + @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) + } +} + +class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + + @Environment(\.openURL) var openURL + var statusItem: NSStatusItem! + var captureState: CaptureState = .idle + var recordingWindow: RecordingWindow? = nil + var preferencesWindow: PreferencesWindow? = nil + var boxListener: AnyCancellable? = nil + var popover: NSPopover? = nil + var helpShown = false + var captureSession: CapturaCaptureSession? = nil + var images: [CGImage] = [] + var outputFile: CapturaFile? = nil + var gifCallbackTimer = ContinuousClock.now + var fps = CapturaSettings.frameRate + var pixelDensity: CGFloat = 1.0 + var stopTimer: DispatchWorkItem? + + func applicationDidFinishLaunching(_ notification: Notification) { + setupMenu() + NotificationCenter.default.addObserver( + self, + selector: #selector(self.didReceiveNotification(_:)), + name: nil, + object: nil) + closeWindow() + } + + // MARK: - Setup Functions + + + private func setupMenu() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") + } + + statusItem.isVisible = true + statusItem.menu = NSMenu() + statusItem.menu?.delegate = self + + // Create the Popover + popover = NSPopover() + popover?.contentViewController = HelpPopoverViewController() + popover?.behavior = .transient + + + let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6") + recordItem.keyEquivalentModifierMask = [.command, .shift] + statusItem.menu?.addItem(recordItem) + statusItem.menu?.addItem(NSMenuItem.separator()) + + let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "") + statusItem.menu?.addItem(preferencesItem) + + let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "") + statusItem.menu?.addItem(quitItem) + } + + private func closeWindow() { + if let window = NSApplication.shared.windows.first { + window.close() + } + } + + // MARK: - UI Event Handlers + + func menuWillOpen(_ menu: NSMenu) { + if captureState != .idle { + menu.cancelTracking() + if captureState == .recording { + NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil) + } + } + } + + @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() + } else { + preferencesWindow?.makeKeyAndOrderFront(nil) + } + } + + @objc private func onQuit() { + NSApplication.shared.terminate(self) + } + + // MARK: - App State Event Listeners + + @objc func didReceiveNotification(_ notification: Notification) { + switch(notification.name) { + case .startAreaSelection: + startAreaSelection() + case .startRecording: + startRecording() + case .stopRecording: + stopRecording() + case .finalizeRecording: + DispatchQueue.main.async { + self.finalizeRecording() + } + case .reset: + reset() + case .failedToStart: + failedToStart() + case .receivedFrame: + if let frame = notification.userInfo?["frame"] { + receivedFrame(frame as! CVImageBuffer) + } + default: + return + } + } + + + func startAreaSelection() { + helpShown = false + NSApp.activate(ignoringOtherApps: true) + if captureState != .selectingArea { + captureState = .selectingArea + if let button = statusItem.button { + let rectInWindow = button.convert(button.bounds, to: nil) + let rectInScreen = button.window?.convertToScreen(rectInWindow) + recordingWindow = RecordingWindow(rectInScreen) + boxListener = recordingWindow?.recordingContentView.$box + .debounce(for: .seconds(0.3), scheduler: RunLoop.main) + .sink { newValue in + if newValue != nil { + self.updateImage() + if !self.helpShown { + self.helpShown = true + self.showPopoverWithMessage("Click here when you're ready to record.") + } + } + } + } + } + } + + func startRecording() { + captureState = .recording + updateImage() + fps = CapturaSettings.frameRate + outputFile = nil + 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() + 300, execute: stopTimer!) + + outputFile = CapturaFile() + if CapturaSettings.shouldSaveMp4 { + captureSession.startRecording(to: outputFile!.mp4URL) + } else { + captureSession.startRunning() + } + return + } + } + } + NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil) + } + + func stopRecording() { + captureState = .uploading + updateImage() + stop() + + if !CapturaSettings.shouldSaveMp4 { + NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) + return + } + + Task.detached { + if let outputFile = self.outputFile { + await GifRenderer.render(self.images, at: self.fps, to: outputFile.gifURL) + NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) + } + } + } + + func finalizeRecording() { + captureState = .uploaded + copyToClipboard() + updateImage() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) + } + } + + func reset() { + captureState = .idle + updateImage() + stop() + } + + func receivedFrame(_ frame: CVImageBuffer) { + let now = ContinuousClock.now + + if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) { + gifCallbackTimer = now + DispatchQueue.main.async { + if let cgImage = frame.cgImage?.resize(by: self.pixelDensity) { + self.images.append(cgImage) + } + } + } + } + + func failedToStart() { + captureState = .error + updateImage() + requestPermissionToRecord() + stop() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) + } + } + + // 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") { + NSWorkspace.shared.open(url) + } + } + + private func showPopoverWithMessage(_ message: String) { + if let button = statusItem.button { + (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message) + self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.popover?.performClose(nil) + } + } + } + + private func updateImage() { + if let button = statusItem.button { + let image: String = switch captureState { + case .idle: + "rectangle.dashed.badge.record" + case .selectingArea: + "circle.rectangle.dashed" + case .recording: + "checkmark.rectangle" + case .uploading: + "dock.arrow.up.rectangle" + case .uploaded: + "checkmark.rectangle.fill" + case .error: + "xmark.rectangle.fill" + } + button.image = NSImage(systemSymbolName: image, accessibilityDescription: "Captura") + } + } + + private func stop() { + stopTimer?.cancel() + captureSession?.stopRunning() + captureSession = nil + boxListener?.cancel() + recordingWindow?.close() + recordingWindow = nil + } + + private func copyToClipboard() { + let fileType: NSPasteboard.PasteboardType = .init(rawValue: CapturaSettings.shouldSaveGif ? "com.compuserve.gif" : "public.mpeg-4") + if let url = CapturaSettings.shouldSaveGif ? outputFile?.gifURL : outputFile?.mp4URL { + if let data = try? Data(contentsOf: url) { + let pasteboard = NSPasteboard.general + pasteboard.declareTypes([fileType], owner: nil) + pasteboard.setData(data, forType: fileType) + } + } + } +} diff --git a/Captura/Core Extensions/CVImageBuffer+cgImage.swift b/Captura/Core Extensions/CVImageBuffer+cgImage.swift new file mode 100644 index 0000000..c30cb0c --- /dev/null +++ b/Captura/Core Extensions/CVImageBuffer+cgImage.swift @@ -0,0 +1,11 @@ +import Foundation +import ReplayKit + +extension CVImageBuffer { + + var cgImage: CGImage? { + let ciImage = CIImage(cvImageBuffer: self) + let context = CIContext() + return context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(self), height: CVPixelBufferGetHeight(self))) + } +} diff --git a/Captura/Core Extensions/Notification+AppEvents.swift b/Captura/Core Extensions/Notification+AppEvents.swift index 809c00e..5d00937 100644 --- a/Captura/Core Extensions/Notification+AppEvents.swift +++ b/Captura/Core Extensions/Notification+AppEvents.swift @@ -6,4 +6,6 @@ extension Notification.Name { static let stopRecording = Notification.Name("stopRecording") static let finalizeRecording = Notification.Name("finalizeRecording") static let reset = Notification.Name("reset") + static let failedToStart = Notification.Name("failedToStart") + static let receivedFrame = Notification.Name("receivedFrame") } diff --git a/Captura/Data/CapturaRemoteFile.swift b/Captura/Data/CapturaRemoteFile.swift new file mode 100644 index 0000000..24d821e --- /dev/null +++ b/Captura/Data/CapturaRemoteFile.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData + +@Model +final class CapturaRemoteFile { + var timestamp: Date + var url: URL + + init(url: URL) { + self.timestamp = Date() + self.url = url + } +} diff --git a/Captura/Data/CapturaSettings.swift b/Captura/Data/CapturaSettings.swift new file mode 100644 index 0000000..d0d04f2 --- /dev/null +++ b/Captura/Data/CapturaSettings.swift @@ -0,0 +1,29 @@ +import Foundation + +struct CapturaSettings { + static var frameRate: Int { + UserDefaults.standard.integer(forKey: "frameRate") + } + + static var outputFormats: OutputFormatSetting { + OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all + } + + static var shouldSaveMp4: Bool { + outputFormats.shouldSaveMp4() + } + + static var shouldSaveGif: Bool { + outputFormats.shouldSaveGif() + } + + + static var shouldSendNotifications: Bool { + get { + UserDefaults.standard.bool(forKey: "shouldSendNotifications") + } + set { + UserDefaults.standard.setValue(newValue, forKey: "shouldSendNotifications") + } + } +} diff --git a/Captura/Data/CaptureState.swift b/Captura/Data/CaptureState.swift deleted file mode 100644 index be354f6..0000000 --- a/Captura/Data/CaptureState.swift +++ /dev/null @@ -1,7 +0,0 @@ -enum CaptureState { - case idle - case selectingArea - case recording - case uploading - case uploaded -} diff --git a/Captura/Domain/CapturaCaptureSession.swift b/Captura/Domain/CapturaCaptureSession.swift new file mode 100644 index 0000000..80240e6 --- /dev/null +++ b/Captura/Domain/CapturaCaptureSession.swift @@ -0,0 +1,60 @@ +import AppKit +import AVFoundation + +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 screenInput = AVCaptureScreenInput(displayID: displayId) + screenInput?.cropRect = box.insetBy(dx: 1, dy: 1) + + if self.canAddInput(screenInput!) { + self.addInput(screenInput!) + } + + 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) { + receivedFrames = true + + guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + 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?) {} +} diff --git a/Captura/Domain/CaptureState.swift b/Captura/Domain/CaptureState.swift new file mode 100644 index 0000000..0761b88 --- /dev/null +++ b/Captura/Domain/CaptureState.swift @@ -0,0 +1,8 @@ +enum CaptureState { + case idle + case selectingArea + case recording + case uploading + case uploaded + case error +} diff --git a/Captura/Item.swift b/Captura/Item.swift deleted file mode 100644 index 22587d8..0000000 --- a/Captura/Item.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Item.swift -// Captura -// -// Created by Ruben Beltran del Rio on 7/24/23. -// - -import Foundation -import SwiftData - -@Model -final class Item { - var timestamp: Date - - init(timestamp: Date) { - self.timestamp = timestamp - } -} diff --git a/Captura/Presentation/Screens/PreferencesScreen.swift b/Captura/Presentation/Screens/PreferencesScreen.swift index f809502..a9c380b 100644 --- a/Captura/Presentation/Screens/PreferencesScreen.swift +++ b/Captura/Presentation/Screens/PreferencesScreen.swift @@ -2,9 +2,6 @@ import SwiftUI import SwiftData struct PreferencesScreen: View { - @Environment(\.modelContext) private var modelContext - @Query private var items: [Item] - var body: some View { TabView { OutputSettings().tabItem { @@ -13,11 +10,10 @@ struct PreferencesScreen: View { AdvancedSettings().tabItem { Label("Advanced", systemImage: "gear") }.padding(8.0) - }.padding(16.0) + }.padding(16.0).frame(minWidth: 300, minHeight: 250) } } #Preview { PreferencesScreen() - .modelContainer(for: Item.self, inMemory: true) } diff --git a/Captura/Presentation/Settings/AdvancedSettings.swift b/Captura/Presentation/Settings/AdvancedSettings.swift index 7ed78d9..e5b77ce 100644 --- a/Captura/Presentation/Settings/AdvancedSettings.swift +++ b/Captura/Presentation/Settings/AdvancedSettings.swift @@ -3,25 +3,45 @@ import SwiftUI struct AdvancedSettings: View { @AppStorage("backendUrl") var backendUrl: String = "" + @AppStorage("backendFormat") var outputFormats: OutputFormatSetting = .gifOnly @AppStorage("keepFiles") var keepFiles = true + var parsedBackendUrl: URL? { + URL(string: backendUrl) + } + var body: some View { Form { - VStack (alignment: .leading) { + VStack (alignment: .center) { LabeledContent("Backend URL") { - TextField("", text: $backendUrl) - }.font(.headline) - LabeledContent("Keep files after remote upload") { - Toggle("", isOn: $keepFiles) + TextField("", text: $backendUrl).font(.body) }.font(.headline) + Picker(selection: $outputFormats, label: Text("Backend Format").font(.headline)) { + Text("GIF") + .tag(OutputFormatSetting.gifOnly) + .padding(.horizontal, 4.0) + .padding(.vertical, 2.0) + Text("MP4") + .tag(OutputFormatSetting.mp4Only) + .padding(.horizontal, 4.0) + .padding(.vertical, 2.0) + } + .pickerStyle(.radioGroup) + .disabled(parsedBackendUrl == nil) + Toggle("Keep Local Files", isOn: $keepFiles) + .font(.headline) + .disabled(parsedBackendUrl == nil) + .padding(.vertical, 8.0) HStack { - Text("These settings can break things!") + Text("These settings can break things! Please make sure you understand how to use them before enabling.") + .lineLimit(3...10) Button { print("Not yet!") } label: { Image(systemName: "info.circle") }.buttonStyle(.borderless) } + Spacer() } } } diff --git a/Captura/Presentation/Settings/OutputSettings.swift b/Captura/Presentation/Settings/OutputSettings.swift index 3b8e01c..df12541 100644 --- a/Captura/Presentation/Settings/OutputSettings.swift +++ b/Captura/Presentation/Settings/OutputSettings.swift @@ -7,14 +7,14 @@ struct OutputSettings: View { var body: some View { Form { - VStack (alignment: .leading) { + VStack (alignment: .center) { LabeledContent("GIF Framerate") { Slider(value: $frameRate, in: 4...10, step: 1) { Text("\(Int(frameRate))").font(.body).frame(width: 24) } minimumValueLabel: { Text("4") } maximumValueLabel: { - Text("12") + Text("10") } }.font(.headline) Picker(selection: $outputFormats, label: Text("Output Formats").font(.headline)) { @@ -33,6 +33,7 @@ struct OutputSettings: View { .padding(.vertical, 2.0) }.pickerStyle(.radioGroup) } + Spacer() } } } diff --git a/Captura/Presentation/Windows/CapturaApp.swift b/Captura/Presentation/Windows/CapturaApp.swift deleted file mode 100644 index 6cf7044..0000000 --- a/Captura/Presentation/Windows/CapturaApp.swift +++ /dev/null @@ -1,329 +0,0 @@ -import SwiftUI -import SwiftData -import Cocoa -import Combine -import ReplayKit - -@main -struct CapturaApp: App { - - @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: Item.self) - } -} - -class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate { - - @Environment(\.openURL) var openURL - var statusItem: NSStatusItem! - var captureState: CaptureState = .idle - var recordingWindow: RecordingWindow? = nil - var preferencesWindow: PreferencesWindow? = nil - var boxListener: AnyCancellable? = nil - var popover: NSPopover? = nil - var helpShown = false - var receivedFrames = false - var captureSession: AVCaptureSession? = nil - var images: [CGImage] = [] - var outputFile: CapturaFile? = nil - var gifCallbackTimer = ContinuousClock.now - var fps = UserDefaults.standard.integer(forKey: "frameRate") - var pixelDensity: CGFloat = 1.0 - var stopTimer: DispatchWorkItem? - - func applicationDidFinishLaunching(_ notification: Notification) { - setupMenu() - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didReceiveNotification(_:)), - name: nil, - object: nil) - closeWindow() - } - - // MARK: - Setup Functions - - - private func setupMenu() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") - } - - statusItem.isVisible = true - statusItem.menu = NSMenu() - statusItem.menu?.delegate = self - - // Create the Popover - popover = NSPopover() - popover?.contentViewController = HelpPopoverViewController() - popover?.behavior = .transient - - - let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6") - recordItem.keyEquivalentModifierMask = [.command, .shift] - statusItem.menu?.addItem(recordItem) - statusItem.menu?.addItem(NSMenuItem.separator()) - - let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "") - statusItem.menu?.addItem(preferencesItem) - - let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "") - statusItem.menu?.addItem(quitItem) - } - - private func closeWindow() { - if let window = NSApplication.shared.windows.first { - window.close() - } - } - - // MARK: - UI Event Handlers - - func menuWillOpen(_ menu: NSMenu) { - if captureState != .idle { - menu.cancelTracking() - if captureState == .recording { - NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil) - } - } - } - - @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() - } else { - preferencesWindow?.makeKeyAndOrderFront(nil) - } - } - - @objc private func onQuit() { - NSApplication.shared.terminate(self) - } - - // MARK: - App State Event Listeners - - @objc func didReceiveNotification(_ notification: Notification) { - switch(notification.name) { - case .startAreaSelection: - startAreaSelection() - case .startRecording: - startRecording() - case .stopRecording: - stopRecording() - case .finalizeRecording: - DispatchQueue.main.async { - self.finalizeRecording() - } - case .reset: - reset() - default: - return - } - /* - if let data = notification.userInfo?["data"] as? String { - print("Data received: \(data)") - } - */ - } - - - @objc func startAreaSelection() { - helpShown = false - NSApp.activate(ignoringOtherApps: true) - if captureState != .selectingArea { - captureState = .selectingArea - if let button = statusItem.button { - let rectInWindow = button.convert(button.bounds, to: nil) - let rectInScreen = button.window?.convertToScreen(rectInWindow) - recordingWindow = RecordingWindow(rectInScreen) - if let view = recordingWindow?.contentView as? RecordingContentView { - boxListener = view.$box - .debounce(for: .seconds(0.3), scheduler: RunLoop.main) - .sink { newValue in - if newValue != nil { - button.image = NSImage(systemSymbolName: "circle.rectangle.dashed", accessibilityDescription: "Captura") - if !self.helpShown { - self.helpShown = true - self.showPopoverWithMessage("Click here when you're ready to record.") - } - } - } - } - } - } - } - - func startRecording() { - captureState = .recording - fps = UserDefaults.standard.integer(forKey: "frameRate") - outputFile = nil - images = []; - pixelDensity = recordingWindow?.pixelDensity ?? 1.0 - if let view = recordingWindow?.contentView as? RecordingContentView { - view.startRecording() - if let box = view.box { - if let screen = NSScreen.main { - let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID - let screenInput = AVCaptureScreenInput(displayID: displayId) - screenInput?.cropRect = box.insetBy(dx: 1, dy: 1) - - captureSession = AVCaptureSession() - - if let captureSession { - - - if captureSession.canAddInput(screenInput!) { - captureSession.addInput(screenInput!) - } - - let videoOutput = AVCaptureVideoDataOutput() - videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "sample buffer delegate", attributes: [])) - - if captureSession.canAddOutput(videoOutput) { - captureSession.addOutput(videoOutput) - } - - let movieFileOutput = AVCaptureMovieFileOutput() - if captureSession.canAddOutput(movieFileOutput) { - captureSession.addOutput(movieFileOutput) - } - - stopTimer = DispatchWorkItem { - self.stopRecording() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!) - - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "checkmark.rectangle", accessibilityDescription: "Captura") - } - - receivedFrames = false - captureSession.startRunning() - - outputFile = CapturaFile() - let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all - if outputFormatsSetting.shouldSaveMp4() { - movieFileOutput.startRecording(to: outputFile!.mp4URL, recordingDelegate: self) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if !self.receivedFrames { - self.requestPermission() - } - } - } - - - } else { - print("Should error") - } - } - } - } - - func stopRecording() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "dock.arrow.up.rectangle", accessibilityDescription: "Captura") - } - stopTimer?.cancel() - captureState = .uploading - captureSession?.stopRunning() - captureSession = nil - boxListener?.cancel() - recordingWindow?.close() - self.recordingWindow = nil - - let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all - if !outputFormatsSetting.shouldSaveGif() { - NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) - return - } - - Task.detached { - if let outputFile = self.outputFile { - await GifRenderer.render(self.images, at: self.fps, to: outputFile.gifURL) - NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) - } - } - } - - func finalizeRecording() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "checkmark.rectangle.fill", accessibilityDescription: "Captura") - } - captureState = .uploaded - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.reset() - } - } - - func reset() { - if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") - } - captureState = .idle - stopTimer?.cancel() - captureSession?.stopRunning() - boxListener?.cancel() - recordingWindow?.close() - self.recordingWindow = nil - } - - private func requestPermission() { - reset() - showPopoverWithMessage("Please grant Captura permission to record") - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") { - NSWorkspace.shared.open(url) - } - } - - func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { - receivedFrames = true - - let now = ContinuousClock.now - - if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) { - gifCallbackTimer = now - DispatchQueue.main.async { - // Get the CVImageBuffer from the sample buffer - guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } - let ciImage = CIImage(cvImageBuffer: imageBuffer) - let context = CIContext() - if let cgImage = context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer))) { - if let cgImage = cgImage.resize(by: self.pixelDensity) { - self.images.append(cgImage) - } - } - } - } - } - - private func showPopoverWithMessage(_ message: String) { - if let button = statusItem.button { - (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message) - self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.popover?.performClose(nil) - } - } - } - - // MARK: - AVCaptureFileOutputRecordingDelegate Implementation - - func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {} -} diff --git a/Captura/Presentation/Windows/RecordingWindow.swift b/Captura/Presentation/Windows/RecordingWindow.swift index 2bb9928..b9f212c 100644 --- a/Captura/Presentation/Windows/RecordingWindow.swift +++ b/Captura/Presentation/Windows/RecordingWindow.swift @@ -7,6 +7,10 @@ class RecordingWindow: NSWindow { self.screen?.backingScaleFactor ?? 1.0 } + var recordingContentView: RecordingContentView { + self.contentView as! RecordingContentView + } + init(_ button: NSRect?) { let screens = NSScreen.screens diff --git a/README.md b/README.md new file mode 100644 index 0000000..9230a9d --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Captura + +Bring your own backend screen recorder + +## Bring Your Own Backend (Protocol version 1.0) + +Captura allows you to define a URL that will be used to POST the recording. + +### The Request + +A `POST` will be made to the request, with the payload containing the binary +contents of the file. You can expect the following headers: + +* `User-Agent`: `Captura/1.0` +* `Content-Type`: `video/mp4` or `image/gif`, depending on the output format + selected. + +### Authentication / Authorization + +We will only do a POST request, so if you need any type of keys or user +identifiers, include them in the URL itself (eg. via path elements or +in the query parameters) + +### Expected Response + +If the upload is successful, the response *MUST* be a JSON object containing +a key called `url` with a value of type `string` corresponding to the URL +where the file is available. The status code *MUST* be 201 Created. + +Any response code other than 201 Created will be treated as an error. Captura +will not re-attempt an upload. -- cgit