]> git.r.bdr.sh - rbdr/captura/blob - Captura/Presentation/Windows/CapturaApp.swift
6cf70445038f54856ac745ab48b2f46830586477
[rbdr/captura] / Captura / Presentation / Windows / CapturaApp.swift
1 import SwiftUI
2 import SwiftData
3 import Cocoa
4 import Combine
5 import ReplayKit
6
7 @main
8 struct CapturaApp: App {
9
10 @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate
11
12 var body: some Scene {
13 WindowGroup {
14 PreferencesScreen()
15 .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesScreen"), allowing: Set(arrayLiteral: "*"))
16 .frame(width: 650, height: 450)
17 }
18 .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen"))
19 .modelContainer(for: Item.self)
20 }
21 }
22
23 class CapturaAppDelegate: NSObject, NSApplicationDelegate, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureFileOutputRecordingDelegate, NSMenuDelegate {
24
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
32 var helpShown = false
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?
41
42 func applicationDidFinishLaunching(_ notification: Notification) {
43 setupMenu()
44 NotificationCenter.default.addObserver(
45 self,
46 selector: #selector(self.didReceiveNotification(_:)),
47 name: nil,
48 object: nil)
49 closeWindow()
50 }
51
52 // MARK: - Setup Functions
53
54
55 private func setupMenu() {
56 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
57
58 if let button = statusItem.button {
59 button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
60 }
61
62 statusItem.isVisible = true
63 statusItem.menu = NSMenu()
64 statusItem.menu?.delegate = self
65
66 // Create the Popover
67 popover = NSPopover()
68 popover?.contentViewController = HelpPopoverViewController()
69 popover?.behavior = .transient
70
71
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())
76
77 let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "")
78 statusItem.menu?.addItem(preferencesItem)
79
80 let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "")
81 statusItem.menu?.addItem(quitItem)
82 }
83
84 private func closeWindow() {
85 if let window = NSApplication.shared.windows.first {
86 window.close()
87 }
88 }
89
90 // MARK: - UI Event Handlers
91
92 func menuWillOpen(_ menu: NSMenu) {
93 if captureState != .idle {
94 menu.cancelTracking()
95 if captureState == .recording {
96 NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil)
97 }
98 }
99 }
100
101 @objc private func onClickStartRecording() {
102 NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil)
103 }
104
105 @objc private func onOpenPreferences() {
106 NSApp.activate(ignoringOtherApps: true)
107 if preferencesWindow == nil {
108 preferencesWindow = PreferencesWindow()
109 } else {
110 preferencesWindow?.makeKeyAndOrderFront(nil)
111 }
112 }
113
114 @objc private func onQuit() {
115 NSApplication.shared.terminate(self)
116 }
117
118 // MARK: - App State Event Listeners
119
120 @objc func didReceiveNotification(_ notification: Notification) {
121 switch(notification.name) {
122 case .startAreaSelection:
123 startAreaSelection()
124 case .startRecording:
125 startRecording()
126 case .stopRecording:
127 stopRecording()
128 case .finalizeRecording:
129 DispatchQueue.main.async {
130 self.finalizeRecording()
131 }
132 case .reset:
133 reset()
134 default:
135 return
136 }
137 /*
138 if let data = notification.userInfo?["data"] as? String {
139 print("Data received: \(data)")
140 }
141 */
142 }
143
144
145 @objc func startAreaSelection() {
146 helpShown = false
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)
157 .sink { newValue in
158 if newValue != nil {
159 button.image = NSImage(systemSymbolName: "circle.rectangle.dashed", accessibilityDescription: "Captura")
160 if !self.helpShown {
161 self.helpShown = true
162 self.showPopoverWithMessage("Click here when you're ready to record.")
163 }
164 }
165 }
166 }
167 }
168 }
169 }
170
171 func startRecording() {
172 captureState = .recording
173 fps = UserDefaults.standard.integer(forKey: "frameRate")
174 outputFile = nil
175 images = [];
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)
184
185 captureSession = AVCaptureSession()
186
187 if let captureSession {
188
189
190 if captureSession.canAddInput(screenInput!) {
191 captureSession.addInput(screenInput!)
192 }
193
194 let videoOutput = AVCaptureVideoDataOutput()
195 videoOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "sample buffer delegate", attributes: []))
196
197 if captureSession.canAddOutput(videoOutput) {
198 captureSession.addOutput(videoOutput)
199 }
200
201 let movieFileOutput = AVCaptureMovieFileOutput()
202 if captureSession.canAddOutput(movieFileOutput) {
203 captureSession.addOutput(movieFileOutput)
204 }
205
206 stopTimer = DispatchWorkItem {
207 self.stopRecording()
208 }
209 DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!)
210
211 if let button = statusItem.button {
212 button.image = NSImage(systemSymbolName: "checkmark.rectangle", accessibilityDescription: "Captura")
213 }
214
215 receivedFrames = false
216 captureSession.startRunning()
217
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)
222 }
223
224 DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
225 if !self.receivedFrames {
226 self.requestPermission()
227 }
228 }
229 }
230
231
232 } else {
233 print("Should error")
234 }
235 }
236 }
237 }
238
239 func stopRecording() {
240 if let button = statusItem.button {
241 button.image = NSImage(systemSymbolName: "dock.arrow.up.rectangle", accessibilityDescription: "Captura")
242 }
243 stopTimer?.cancel()
244 captureState = .uploading
245 captureSession?.stopRunning()
246 captureSession = nil
247 boxListener?.cancel()
248 recordingWindow?.close()
249 self.recordingWindow = nil
250
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)
254 return
255 }
256
257 Task.detached {
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)
261 }
262 }
263 }
264
265 func finalizeRecording() {
266 if let button = statusItem.button {
267 button.image = NSImage(systemSymbolName: "checkmark.rectangle.fill", accessibilityDescription: "Captura")
268 }
269 captureState = .uploaded
270 DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
271 self.reset()
272 }
273 }
274
275 func reset() {
276 if let button = statusItem.button {
277 button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura")
278 }
279 captureState = .idle
280 stopTimer?.cancel()
281 captureSession?.stopRunning()
282 boxListener?.cancel()
283 recordingWindow?.close()
284 self.recordingWindow = nil
285 }
286
287 private func requestPermission() {
288 reset()
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)
292 }
293 }
294
295 func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
296 receivedFrames = true
297
298 let now = ContinuousClock.now
299
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)
310 }
311 }
312 }
313 }
314 }
315
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)
322 }
323 }
324 }
325
326 // MARK: - AVCaptureFileOutputRecordingDelegate Implementation
327
328 func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {}
329 }