]>
Commit | Line | Data |
---|---|---|
a4e80427 RBR |
1 | import SwiftUI |
2 | import SwiftData | |
3 | import Cocoa | |
4 | import Combine | |
c9b9e1d6 | 5 | import AVFoundation |
a4e80427 RBR |
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")) | |
c9b9e1d6 | 19 | .modelContainer(for: CapturaRemoteFile.self) |
a4e80427 RBR |
20 | } |
21 | } | |
22 | ||
c9b9e1d6 | 23 | class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { |
a4e80427 RBR |
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 | |
c9b9e1d6 | 33 | var captureSession: CapturaCaptureSession? = nil |
a4e80427 | 34 | var images: [CGImage] = [] |
f5d16c1c | 35 | var outputFile: CapturaFile? = nil |
a4e80427 | 36 | var gifCallbackTimer = ContinuousClock.now |
c9b9e1d6 | 37 | var fps = CapturaSettings.frameRate |
a4e80427 RBR |
38 | var pixelDensity: CGFloat = 1.0 |
39 | var stopTimer: DispatchWorkItem? | |
40 | ||
41 | func applicationDidFinishLaunching(_ notification: Notification) { | |
42 | setupMenu() | |
43 | NotificationCenter.default.addObserver( | |
44 | self, | |
45 | selector: #selector(self.didReceiveNotification(_:)), | |
46 | name: nil, | |
47 | object: nil) | |
48 | closeWindow() | |
49 | } | |
50 | ||
51 | // MARK: - Setup Functions | |
52 | ||
53 | ||
54 | private func setupMenu() { | |
55 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) | |
56 | ||
57 | if let button = statusItem.button { | |
58 | button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") | |
59 | } | |
60 | ||
61 | statusItem.isVisible = true | |
62 | statusItem.menu = NSMenu() | |
63 | statusItem.menu?.delegate = self | |
64 | ||
65 | // Create the Popover | |
66 | popover = NSPopover() | |
67 | popover?.contentViewController = HelpPopoverViewController() | |
68 | popover?.behavior = .transient | |
69 | ||
70 | ||
71 | let recordItem = NSMenuItem(title: "Record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6") | |
72 | recordItem.keyEquivalentModifierMask = [.command, .shift] | |
73 | statusItem.menu?.addItem(recordItem) | |
74 | statusItem.menu?.addItem(NSMenuItem.separator()) | |
75 | ||
76 | let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(CapturaAppDelegate.onOpenPreferences), keyEquivalent: "") | |
77 | statusItem.menu?.addItem(preferencesItem) | |
78 | ||
79 | let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "") | |
80 | statusItem.menu?.addItem(quitItem) | |
81 | } | |
82 | ||
83 | private func closeWindow() { | |
84 | if let window = NSApplication.shared.windows.first { | |
85 | window.close() | |
86 | } | |
87 | } | |
88 | ||
89 | // MARK: - UI Event Handlers | |
90 | ||
91 | func menuWillOpen(_ menu: NSMenu) { | |
92 | if captureState != .idle { | |
93 | menu.cancelTracking() | |
94 | if captureState == .recording { | |
f5d16c1c | 95 | NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil) |
a4e80427 RBR |
96 | } |
97 | } | |
98 | } | |
99 | ||
100 | @objc private func onClickStartRecording() { | |
101 | NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil) | |
102 | } | |
103 | ||
104 | @objc private func onOpenPreferences() { | |
105 | NSApp.activate(ignoringOtherApps: true) | |
106 | if preferencesWindow == nil { | |
107 | preferencesWindow = PreferencesWindow() | |
108 | } else { | |
109 | preferencesWindow?.makeKeyAndOrderFront(nil) | |
110 | } | |
111 | } | |
112 | ||
113 | @objc private func onQuit() { | |
114 | NSApplication.shared.terminate(self) | |
115 | } | |
116 | ||
a4e80427 RBR |
117 | // MARK: - App State Event Listeners |
118 | ||
119 | @objc func didReceiveNotification(_ notification: Notification) { | |
120 | switch(notification.name) { | |
121 | case .startAreaSelection: | |
122 | startAreaSelection() | |
123 | case .startRecording: | |
124 | startRecording() | |
125 | case .stopRecording: | |
126 | stopRecording() | |
127 | case .finalizeRecording: | |
f5d16c1c RBR |
128 | DispatchQueue.main.async { |
129 | self.finalizeRecording() | |
130 | } | |
a4e80427 RBR |
131 | case .reset: |
132 | reset() | |
c9b9e1d6 RBR |
133 | case .failedToStart: |
134 | failedToStart() | |
135 | case .receivedFrame: | |
136 | if let frame = notification.userInfo?["frame"] { | |
137 | receivedFrame(frame as! CVImageBuffer) | |
138 | } | |
a4e80427 RBR |
139 | default: |
140 | return | |
141 | } | |
a4e80427 RBR |
142 | } |
143 | ||
144 | ||
c9b9e1d6 | 145 | func startAreaSelection() { |
a4e80427 RBR |
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) | |
c9b9e1d6 RBR |
154 | boxListener = recordingWindow?.recordingContentView.$box |
155 | .debounce(for: .seconds(0.3), scheduler: RunLoop.main) | |
156 | .sink { newValue in | |
157 | if newValue != nil { | |
158 | self.updateImage() | |
159 | if !self.helpShown { | |
160 | self.helpShown = true | |
161 | self.showPopoverWithMessage("Click here when you're ready to record.") | |
a4e80427 RBR |
162 | } |
163 | } | |
c9b9e1d6 | 164 | } |
a4e80427 RBR |
165 | } |
166 | } | |
167 | } | |
168 | ||
169 | func startRecording() { | |
170 | captureState = .recording | |
c9b9e1d6 RBR |
171 | updateImage() |
172 | fps = CapturaSettings.frameRate | |
f5d16c1c | 173 | outputFile = nil |
a4e80427 RBR |
174 | images = []; |
175 | pixelDensity = recordingWindow?.pixelDensity ?? 1.0 | |
c9b9e1d6 RBR |
176 | recordingWindow?.recordingContentView.startRecording() |
177 | if let box = recordingWindow?.recordingContentView.box { | |
178 | if let screen = recordingWindow?.screen { | |
179 | captureSession = CapturaCaptureSession(screen, box: box) | |
180 | ||
181 | if let captureSession { | |
182 | ||
183 | stopTimer = DispatchWorkItem { | |
184 | self.stopRecording() | |
185 | } | |
186 | DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!) | |
a4e80427 | 187 | |
c9b9e1d6 RBR |
188 | outputFile = CapturaFile() |
189 | if CapturaSettings.shouldSaveMp4 { | |
190 | captureSession.startRecording(to: outputFile!.mp4URL) | |
191 | } else { | |
a4e80427 | 192 | captureSession.startRunning() |
a4e80427 | 193 | } |
c9b9e1d6 | 194 | return |
a4e80427 RBR |
195 | } |
196 | } | |
197 | } | |
c9b9e1d6 | 198 | NotificationCenter.default.post(name: .failedToStart, object: nil, userInfo: nil) |
a4e80427 RBR |
199 | } |
200 | ||
201 | func stopRecording() { | |
a4e80427 | 202 | captureState = .uploading |
c9b9e1d6 RBR |
203 | updateImage() |
204 | stop() | |
f5d16c1c | 205 | |
c9b9e1d6 | 206 | if !CapturaSettings.shouldSaveMp4 { |
f5d16c1c RBR |
207 | NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) |
208 | return | |
209 | } | |
210 | ||
a4e80427 | 211 | Task.detached { |
f5d16c1c RBR |
212 | if let outputFile = self.outputFile { |
213 | await GifRenderer.render(self.images, at: self.fps, to: outputFile.gifURL) | |
214 | NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) | |
a4e80427 RBR |
215 | } |
216 | } | |
a4e80427 RBR |
217 | } |
218 | ||
219 | func finalizeRecording() { | |
220 | captureState = .uploaded | |
c9b9e1d6 RBR |
221 | copyToClipboard() |
222 | updateImage() | |
f5d16c1c | 223 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { |
c9b9e1d6 | 224 | NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) |
f5d16c1c | 225 | } |
a4e80427 RBR |
226 | } |
227 | ||
228 | func reset() { | |
a4e80427 | 229 | captureState = .idle |
c9b9e1d6 RBR |
230 | updateImage() |
231 | stop() | |
a4e80427 RBR |
232 | } |
233 | ||
c9b9e1d6 | 234 | func receivedFrame(_ frame: CVImageBuffer) { |
a4e80427 RBR |
235 | let now = ContinuousClock.now |
236 | ||
237 | if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(fps)) { | |
238 | gifCallbackTimer = now | |
239 | DispatchQueue.main.async { | |
c9b9e1d6 RBR |
240 | if let cgImage = frame.cgImage?.resize(by: self.pixelDensity) { |
241 | self.images.append(cgImage) | |
a4e80427 RBR |
242 | } |
243 | } | |
244 | } | |
245 | } | |
246 | ||
c9b9e1d6 RBR |
247 | func failedToStart() { |
248 | captureState = .error | |
249 | updateImage() | |
250 | requestPermissionToRecord() | |
251 | stop() | |
252 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { | |
253 | NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) | |
254 | } | |
255 | } | |
256 | ||
257 | // MARK: - Presentation Helpers | |
258 | ||
259 | ||
260 | private func requestPermissionToRecord() { | |
261 | showPopoverWithMessage("Please grant Captura permission to record") | |
262 | if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") { | |
263 | NSWorkspace.shared.open(url) | |
264 | } | |
265 | } | |
266 | ||
a4e80427 RBR |
267 | private func showPopoverWithMessage(_ message: String) { |
268 | if let button = statusItem.button { | |
269 | (self.popover?.contentViewController as? HelpPopoverViewController)?.updateLabel(message) | |
270 | self.popover?.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) | |
271 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { | |
272 | self.popover?.performClose(nil) | |
273 | } | |
274 | } | |
275 | } | |
276 | ||
c9b9e1d6 RBR |
277 | private func updateImage() { |
278 | if let button = statusItem.button { | |
279 | let image: String = switch captureState { | |
280 | case .idle: | |
281 | "rectangle.dashed.badge.record" | |
282 | case .selectingArea: | |
283 | "circle.rectangle.dashed" | |
284 | case .recording: | |
285 | "checkmark.rectangle" | |
286 | case .uploading: | |
287 | "dock.arrow.up.rectangle" | |
288 | case .uploaded: | |
289 | "checkmark.rectangle.fill" | |
290 | case .error: | |
291 | "xmark.rectangle.fill" | |
292 | } | |
293 | button.image = NSImage(systemSymbolName: image, accessibilityDescription: "Captura") | |
294 | } | |
295 | } | |
296 | ||
297 | private func stop() { | |
298 | stopTimer?.cancel() | |
299 | captureSession?.stopRunning() | |
300 | captureSession = nil | |
301 | boxListener?.cancel() | |
302 | recordingWindow?.close() | |
303 | recordingWindow = nil | |
304 | } | |
a4e80427 | 305 | |
c9b9e1d6 RBR |
306 | private func copyToClipboard() { |
307 | let fileType: NSPasteboard.PasteboardType = .init(rawValue: CapturaSettings.shouldSaveGif ? "com.compuserve.gif" : "public.mpeg-4") | |
308 | if let url = CapturaSettings.shouldSaveGif ? outputFile?.gifURL : outputFile?.mp4URL { | |
309 | if let data = try? Data(contentsOf: url) { | |
310 | let pasteboard = NSPasteboard.general | |
311 | pasteboard.declareTypes([fileType], owner: nil) | |
312 | pasteboard.setData(data, forType: fileType) | |
313 | } | |
314 | } | |
315 | } | |
a4e80427 | 316 | } |