X-Git-Url: https://git.r.bdr.sh/rbdr/captura/blobdiff_plain/c9b9e1d654ea697afad9f6427d94623bfdf55cce..cdc79b7d7c4829ba7a0371826b28f398f267c46a:/Captura/CapturaApp.swift?ds=sidebyside diff --git a/Captura/CapturaApp.swift b/Captura/CapturaApp.swift index 4f1ceff..0d22e79 100644 --- a/Captura/CapturaApp.swift +++ b/Captura/CapturaApp.swift @@ -1,12 +1,11 @@ import SwiftUI -import SwiftData import Cocoa import Combine import AVFoundation @main struct CapturaApp: App { - + @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate var body: some Scene { @@ -16,11 +15,11 @@ struct CapturaApp: App { .frame(width: 650, height: 450) } .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesScreen")) - .modelContainer(for: CapturaRemoteFile.self) + //.modelContainer(for: CapturaRemoteFile.self) } } -class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { +@objc(CapturaAppDelegate) class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { @Environment(\.openURL) var openURL var statusItem: NSStatusItem! @@ -34,24 +33,28 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { var images: [CGImage] = [] var outputFile: CapturaFile? = nil var gifCallbackTimer = ContinuousClock.now - var fps = CapturaSettings.frameRate var pixelDensity: CGFloat = 1.0 var stopTimer: DispatchWorkItem? + var remoteFiles: [CapturaRemoteFile] = [] + var captureSessionConfiguration: CaptureSessionConfiguration = CaptureSessionConfiguration() + + @objc dynamic var scriptedPreferences: ScriptedPreferences = ScriptedPreferences() func applicationDidFinishLaunching(_ notification: Notification) { - setupMenu() + setupStatusBar() NotificationCenter.default.addObserver( self, selector: #selector(self.didReceiveNotification(_:)), name: nil, object: nil) closeWindow() + fetchRemoteItems() } // MARK: - Setup Functions - private func setupMenu() { + private func setupStatusBar() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = statusItem.button { @@ -67,17 +70,27 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { popover?.contentViewController = HelpPopoverViewController() popover?.behavior = .transient + setupMenu() + } + + private func setupMenu() { - 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) + statusItem.menu?.removeAllItems() - let quitItem = NSMenuItem(title: "Quit", action: #selector(CapturaAppDelegate.onQuit), keyEquivalent: "") - statusItem.menu?.addItem(quitItem) + 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: "") + 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.separator()) + 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() { @@ -85,6 +98,35 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { window.close() } } + + // MARK: - URL Event Handler + + func application(_ application: NSApplication, open urls: [URL]) { + 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 + ]) + 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() + } + } // MARK: - UI Event Handlers @@ -107,6 +149,23 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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") { + NSWorkspace.shared.open(directory) + } + } + + @objc private func onClickRemoteFile(_ sender: NSMenuItem) { + if let remoteFile = sender.representedObject as? CapturaRemoteFile { + if let urlString = remoteFile.url { + if let url = URL(string: urlString) { + NSWorkspace.shared.open(url) + } + } } } @@ -131,11 +190,38 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { case .reset: reset() case .failedToStart: - failedToStart() + DispatchQueue.main.async { + self.failed(true) + } + case .failedtoUpload: + DispatchQueue.main.async { + self.failed() + } case .receivedFrame: if let frame = notification.userInfo?["frame"] { receivedFrame(frame as! CVImageBuffer) } + case .setConfiguration: + DispatchQueue.main.async { + if let userInfo = notification.userInfo { + if let config = userInfo["config"] as? ConfigureAction { + self.setConfiguration(config) + } + } + } + case .reloadConfiguration: + reloadConfiguration() + case .setCaptureSessionConfiguration: + if let userInfo = notification.userInfo { + if let config = userInfo["config"] as? RecordAction { + setCaptureSessionConfiguration(config) + } + } + case .NSManagedObjectContextObjectsDidChange: + DispatchQueue.main.async { + self.fetchRemoteItems() + self.setupMenu() + } default: return } @@ -144,13 +230,15 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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) + NSApp.activate(ignoringOtherApps: true) + recordingWindow = RecordingWindow(captureSessionConfiguration, rectInScreen) + recordingWindow?.makeKeyAndOrderFront(nil) + recordingWindow?.orderFrontRegardless() boxListener = recordingWindow?.recordingContentView.$box .debounce(for: .seconds(0.3), scheduler: RunLoop.main) .sink { newValue in @@ -169,7 +257,6 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func startRecording() { captureState = .recording updateImage() - fps = CapturaSettings.frameRate outputFile = nil images = []; pixelDensity = recordingWindow?.pixelDensity ?? 1.0 @@ -183,10 +270,10 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { stopTimer = DispatchWorkItem { self.stopRecording() } - DispatchQueue.main.asyncAfter(deadline: .now() + 300, execute: stopTimer!) + DispatchQueue.main.asyncAfter(deadline: .now() + Double(captureSessionConfiguration.maxLength), execute: stopTimer!) outputFile = CapturaFile() - if CapturaSettings.shouldSaveMp4 { + if captureSessionConfiguration.shouldSaveMp4 { captureSession.startRecording(to: outputFile!.mp4URL) } else { captureSession.startRunning() @@ -203,22 +290,23 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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) + if self.captureSessionConfiguration.shouldSaveGif { + if let outputFile = self.outputFile { + await GifRenderer.render(self.images, at: self.captureSessionConfiguration.frameRate, to: outputFile.gifURL) + } + } + let wasSuccessful = await self.uploadOrCopy() + if wasSuccessful { NotificationCenter.default.post(name: .finalizeRecording, object: nil, userInfo: nil) + } else { + NotificationCenter.default.post(name: .failedtoUpload, 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) @@ -228,13 +316,14 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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(fps)) { + if now - gifCallbackTimer > .nanoseconds(1_000_000_000 / UInt64(captureSessionConfiguration.frameRate)) { gifCallbackTimer = now DispatchQueue.main.async { if let cgImage = frame.cgImage?.resize(by: self.pixelDensity) { @@ -244,16 +333,42 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } - func failedToStart() { + func failed(_ requestPermission: Bool = false) { captureState = .error updateImage() - requestPermissionToRecord() + if requestPermission { + requestPermissionToRecord() + } stop() DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { 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(entityName: "CapturaRemoteFile") + fetchRequest.fetchLimit = 5 + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)] + + let results = try? viewContext.fetch(fetchRequest) + remoteFiles = results ?? [] + } + // MARK: - Presentation Helpers @@ -303,9 +418,22 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { 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 { + private func uploadOrCopy() async -> Bool { + if captureSessionConfiguration.shouldUseBackend { + let result = await uploadToBackend() + if result && !captureSessionConfiguration.shouldKeepLocalFiles { + deleteLocalFiles() + } + return result + } else { + copyLocalToClipboard() + 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 { if let data = try? Data(contentsOf: url) { let pasteboard = NSPasteboard.general pasteboard.declareTypes([fileType], owner: nil) @@ -313,4 +441,56 @@ class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } } + + private func uploadToBackend() async -> Bool { + let contentType = captureSessionConfiguration.shouldUploadGif ? "image/gif" : "video/mp4" + 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.httpMethod = "POST" + 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) + createRemoteFile(answer.url) + return true + } + } + } catch {} + } + } + } + return false + } + + private func createRemoteFile(_ url: URL) { + let viewContext = PersistenceController.shared.container.viewContext + let remoteFile = CapturaRemoteFile(context: viewContext) + remoteFile.url = url.absoluteString + remoteFile.timestamp = Date() + try? viewContext.save() + let pasteboard = NSPasteboard.general + 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) + } + } + if captureSessionConfiguration.shouldSaveMp4 { + if let url = outputFile?.mp4URL { + try? FileManager.default.removeItem(at: url) + } + } + } }