X-Git-Url: https://git.r.bdr.sh/rbdr/captura/blobdiff_plain/242203487a56df9edd1aa6eceb106461ef62483c..refs/heads/main:/Captura/CapturaApp.swift?ds=inline diff --git a/Captura/CapturaApp.swift b/Captura/CapturaApp.swift index 4dc65a4..5fe8858 100644 --- a/Captura/CapturaApp.swift +++ b/Captura/CapturaApp.swift @@ -1,115 +1,568 @@ -import SwiftUI -import SwiftData +import AVFoundation import Cocoa +import Combine +import Sparkle +/* + Copyright (C) 2024 Rubén Beltrán del Río + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see https://captura.tranquil.systems. + */ +import SwiftUI @main struct CapturaApp: App { - - @NSApplicationDelegateAdaptor(CapturaAppDelegate.self) var appDelegate - - var body: some Scene { - WindowGroup { - PreferencesWindow() - .handlesExternalEvents(preferring: Set(arrayLiteral: "PreferencesWindow"), allowing: Set(arrayLiteral: "*")) - .frame(width: 650, height: 450) + + @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) + } +} + +@objc(CapturaAppDelegate) 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 pixelDensity: CGFloat = 1.0 + var stopTimer: DispatchWorkItem? + var remoteFiles: [CapturaRemoteFile] = [] + var captureSessionConfiguration: CaptureSessionConfiguration = CaptureSessionConfiguration() + + // Sparkle Configuration + @IBOutlet var checkForUpdatesMenuItem: NSMenuItem! + let updaterController: SPUStandardUpdaterController = SPUStandardUpdaterController( + startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + + @objc dynamic var scriptedPreferences: ScriptedPreferences = ScriptedPreferences() + + func applicationDidFinishLaunching(_ notification: Notification) { + setupStatusBar() + NotificationCenter.default.addObserver( + self, + selector: #selector(self.didReceiveNotification(_:)), + name: nil, + object: nil) + closeWindow() + fetchRemoteItems() + } + + // MARK: - Setup Functions + + private func setupStatusBar() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem.button { + button.image = NSImage(named: "Idle") + } + + statusItem.isVisible = true + statusItem.menu = NSMenu() + statusItem.menu?.delegate = self + + // Create the Popover + popover = NSPopover() + popover?.contentViewController = HelpPopoverViewController() + popover?.behavior = .transient + + setupMenu() + } + + private func setupMenu() { + + statusItem.menu?.removeAllItems() + + 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()) + + checkForUpdatesMenuItem = NSMenuItem( + title: "Check for Updates", + action: #selector(SPUStandardUpdaterController.checkForUpdates(_:)), keyEquivalent: "") + checkForUpdatesMenuItem.target = updaterController + statusItem.menu?.addItem(checkForUpdatesMenuItem) + + 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() { + if let window = NSApplication.shared.windows.first { + 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) + } } - .handlesExternalEvents(matching: Set(arrayLiteral: "PreferencesWindow")) - .modelContainer(for: Item.self) } -} + } 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 -class CapturaAppDelegate: NSObject, NSApplicationDelegate { - - @Environment(\.openURL) var openURL - var statusItem: NSStatusItem! - var captureState: CaptureState = .idle - var recordingWindow: RecordingWindow? = nil - - 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) - - statusItem.button!.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") - statusItem.isVisible = true - statusItem.menu = NSMenu() - - let recordItem = NSMenuItem(title: "record", action: #selector(CapturaAppDelegate.onClickStartRecording), keyEquivalent: "6") - recordItem.keyEquivalentModifierMask = [.command, .shift] - - statusItem.menu?.addItem(recordItem) - } - - private func closeWindow() { - if let window = NSApplication.shared.windows.first { - window.close() - } - } - - // MARK: - UI Event Handlers - - @objc private func onClickStartRecording() { - NotificationCenter.default.post(name: .startAreaSelection, object: nil, userInfo: nil) - } - - - // MARK: - App State Event Listeners - - @objc func didReceiveNotification(_ notification: Notification) { - switch(notification.name) { - case .startAreaSelection: - startAreaSelection() - case .startRecording: - startRecording() - case .stopRecording: - stopRecording() - case .finalizeRecording: - finalizeRecording() - case .reset: - reset() - default: + func menuWillOpen(_ menu: NSMenu) { + if captureState != .idle { + menu.cancelTrackingWithoutAnimation() + if captureState == .selectingArea { + NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil) return } -/* - if let data = notification.userInfo?["data"] as? String { - print("Data received: \(data)") + if captureState == .recording { + NotificationCenter.default.post(name: .stopRecording, object: nil, userInfo: nil) + return } - */ } + } + + @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) + 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) + } + } + } + } + + @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: + 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 + } + } + + func startAreaSelection() { + helpShown = false + if captureState != .selectingArea { + captureState = .selectingArea + updateImage() + if let button = statusItem.button { + let rectInWindow = button.convert(button.bounds, to: nil) + let rectInScreen = button.window?.convertToScreen(rectInWindow) + 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 + 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() + 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() + Double(captureSessionConfiguration.maxLength), execute: stopTimer!) + + outputFile = CapturaFile() + if captureSessionConfiguration.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() + + Task.detached { + 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 + updateImage() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + NotificationCenter.default.post(name: .reset, object: nil, userInfo: nil) + } + } + + 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(captureSessionConfiguration.frameRate)) + { + gifCallbackTimer = now + DispatchQueue.main.async { + if var cgImage = frame.cgImage { + if self.pixelDensity > 1 { + cgImage = cgImage.resize(by: self.pixelDensity) ?? cgImage + } + self.images.append(cgImage) + } + } + } + } + + func failed(_ requestPermission: Bool = false) { + captureState = .error + updateImage() + 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 ?? [] + } - - @objc func startAreaSelection() { - if captureState != .selectingArea { - captureState = .selectingArea - recordingWindow = RecordingWindow() - print("Recording") + // 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) } } - - func startRecording() { - captureState = .recording + } + + private func updateImage() { + if let button = statusItem.button { + let image: String = + switch captureState { + case .idle: + "Idle" + case .selectingArea: + if recordingWindow?.recordingContentView.box != nil { + "Ready to Record" + } else { + "Selecting" + } + case .recording: + "Stop Frame 1" + case .uploading: + "Upload Frame 1" + case .uploaded: + "OK" + case .error: + "ERR" + } + button.image = NSImage(named: image) } - - func stopRecording() { - captureState = .uploading + } + + private func stop() { + stopTimer?.cancel() + captureSession?.stopRunning() + captureSession = nil + boxListener?.cancel() + recordingWindow?.close() + recordingWindow = nil + } + + 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) + pasteboard.setData(data, forType: fileType) + } + } + } + + 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 {} + } + } } - - func finalizeRecording() { - captureState = .uploaded + 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) + } } - - func reset() { - captureState = .idle + if captureSessionConfiguration.shouldSaveMp4 { + if let url = outputFile?.mp4URL { + try? FileManager.default.removeItem(at: url) + } } + } }