8 struct CapturaApp: App {
10 @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
12 var body: some Scene {
15 .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*"))
16 .frame(width: 650, height: 450)
18 .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
19 .modelContainer(for: Item.self)
23 class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate {
25 @Environment(\.openURL) var openURL
26 var statusItem: NSStatusItem!
27 var captureState: CaptureState = .idle
28 var recordingWindow: RecordingWindow? = nil
29 var preferencesWindow: PreferencesWindow? = nil
30 var boxListener: AnyCancellable? = nil
31 var popover: NSPopover? = nil
33 var receivedFrames = false
34 var captureSession: AVCaptureSession? = nil
35 var images: [CGImage] = []
36 var outputFile: CapturaFile? = nil
37 var gifCallbackTimer = ContinuousClock.now
38 var fps = UserDefaults.standard.integer(forKey: "frameRate")
39 var pixelDensity: CGFloat = 1.0
40 var stopTimer: DispatchWorkItem?
42 func applicationDidFinishLaunching(_ notification: Notification) {
44 NotificationCenter.default.addObserver(
46 selector: #selector(self.didReceiveNotification(_:)),
52 // MARK: - Setup Functions
55 private func setupMenu() {
56 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
58 if let button = statusItem.button {
59 button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
62 statusItem.isVisible = true
63 statusItem.menu = NSMenu()
64 statusItem.menu?.delegate = self
68 popover?.contentViewController = HelpPopoverViewController()
69 popover?.behavior = .transient
72 let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6")
73 recordItem.keyEquivalentModifierMask = [.command, .shift]
74 statusItem.menu?.addItem(recordItem)
75 statusItem.menu?.addItem(NSMenuItem.separator())
77 let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "")
78 statusItem.menu?.addItem(preferencesItem)
80 let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "")
81 statusItem.menu?.addItem(quitItem)
84 private func closeWindow() {
85 if let window = NSApplication.shared.windows.first {
90 // MARK: - UI Event Handlers
92 func menuWillOpen(_ menu: NSMenu) {
93 if captureState != .idle {
95 if captureState == .recording {
96 NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil)
101 @objc private func onClickStartRecording() {
102 NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
105 @objc private func onOpenPreferences() {
106 NSApp.activate(ignoringOtherApps: true)
107 if preferencesWindow == nil {
108 preferencesWindow = PreferencesWindow()
110 preferencesWindow?.makeKeyAndOrderFront(nil)
114 @objc private func onQuit() {
115 NSApplication.shared.terminate(self)
118 // MARK: - App State Event Listeners
120 @objc func didReceiveNotification(_ notification: Notification) {
121 switch(notification.name) {
122 case .startAreaSelection:
124 case .startRecording:
128 case .finalizeRecording:
129 DispatchQueue.main.async {
130 self.finalizeRecording()
138 if let data = notification.userInfo?["data"] as? String {
139 print("Data received: \(data)")
145 @objc func startAreaSelection() {
147 NSApp.activate(ignoringOtherApps: true)
148 if captureState != .selectingArea {
149 captureState = .selectingArea
150 if let button = statusItem.button {
151 let rectInWindow = button.convert(button.bounds, to: nil)
152 let rectInScreen = button.window?.convertToScreen(rectInWindow)
153 recordingWindow = RecordingWindow(rectInScreen)
154 if let view = recordingWindow?.contentView as? RecordingContentView {
155 boxListener = view.$box
156 .debounce(for: .seconds(0.3), scheduler: RunLoop.main)
159 button.image = NSImage(systemSymbolName: "circle.rectangle.dashed", accessibilityDescription: "Captura")
161 self.helpShown = true
162 self.showPopoverWithMessage("Click here when you're ready to record.")
171 func startRecording() {
172 captureState = .recording
173 fps = UserDefaults.standard.integer(forKey: "frameRate")
176 pixelDensity = recordingWindow?.pixelDensity ?? 1.0
177 if let view = recordingWindow?.contentView as? RecordingContentView {
178 view.startRecording()
179 if let box = view.box {
180 if let screen = NSScreen.main {
181 let displayId = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
182 let screenInput = AVCaptureScreenInput(displayID: displayId)
183 screenInput?.cropRect = box.insetBy(dx: 1, dy: 1)
185 captureSession = AVCaptureSession()
187 if let captureSession {
190 if captureSession.canAddInput(screenInput!) {
191 captureSession.addInput(screenInput!)
194 let videoOutput = AVCaptureVideoDataOutput()
195 videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "sample buffer delegate", attributes: []))
197 if captureSession.canAddOutput(videoOutput) {
198 captureSession.addOutput(videoOutput)
201 let movieFileOutput = AVCaptureMovieFileOutput()
202 if captureSession.canAddOutput(movieFileOutput) {
203 captureSession.addOutput(movieFileOutput)
206 stopTimer = DispatchWorkItem {
209 DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!)
211 if let button = statusItem.button {
212 button.image = NSImage(systemSymbolName: "checkmark.rectangle", accessibilityDescription: "Captura")
215 receivedFrames = false
216 captureSession.startRunning()
218 outputFile = CapturaFile()
219 let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
220 if outputFormatsSetting.shouldSaveMp4() {
221 movieFileOutput.startRecording(to: outputFile!.mp4URL, recordingDelegate: self)
224 DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
225 if !self.receivedFrames {
226 self.requestPermission()
233 print("Should error")
239 func stopRecording() {
240 if let button = statusItem.button {
241 button.image = NSImage(systemSymbolName: "dock.arrow.up.rectangle", accessibilityDescription: "Captura")
244 captureState = .uploading
245 captureSession?.stopRunning()
247 boxListener?.cancel()
248 recordingWindow?.close()
249 self.recordingWindow = nil
251 let outputFormatsSetting = OutputFormatSetting(rawValue: UserDefaults.standard.integer(forKey: "outputFormats")) ?? .all
252 if !outputFormatsSetting.shouldSaveGif() {
253 NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil)
258 if let outputFile = self.outputFile {
259 await GifRenderer.render(self.images, at: self.fps, to: outputFile.gifURL)
260 NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil)
265 func finalizeRecording() {
266 if let button = statusItem.button {
267 button.image = NSImage(systemSymbolName: "checkmark.rectangle.fill", accessibilityDescription: "Captura")
269 captureState = .uploaded
270 DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
276 if let button = statusItem.button {
277 button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
281 captureSession?.stopRunning()
282 boxListener?.cancel()
283 recordingWindow?.close()
284 self.recordingWindow = nil
287 private func requestPermission() {
289 showPopoverWithMessage("Please grant Captura permission to record")
290 if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") {
291 NSWorkspace.shared.open(url)
295 func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
296 receivedFrames = true
298 let now = ContinuousClock.now
300 if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) {
301 gifCallbackTimer = now
302 DispatchQueue.main.async {
303 // Get the CVImageBuffer from the sample buffer
304 guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
305 let ciImage = CIImage(cvImageBuffer: imageBuffer)
306 let context = CIContext()
307 if let cgImage = context.createCGImage(ciImage, from: CGRect(x: 0, y: 0, width: CVPixelBufferGetWidth(imageBuffer), height: CVPixelBufferGetHeight(imageBuffer))) {
308 if let cgImage = cgImage.resize(by: self.pixelDensity) {
309 self.images.append(cgImage)
316 private func showPopoverWithMessage(_ message: String) {
317 if let button = statusItem.button {
318 (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message)
319 self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY)
320 DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
321 self.popover?.performClose(nil)
326 // MARK: - AVCaptureFileOutputRecordingDelegate Implementation
328 func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {}