X-Git-Url: https://git.r.bdr.sh/rbdr/captura/blobdiff_plain/cdc79b7d7c4829ba7a0371826b28f398f267c46a..47eb1128eb930279d0fcf2e836d78372ac7ef5c3:/Captura/CapturaApp.swift diff --git a/Captura/CapturaApp.swift b/Captura/CapturaApp.swift index 0d22e79..deb1e07 100644 --- a/Captura/CapturaApp.swift +++ b/Captura/CapturaApp.swift @@ -1,26 +1,46 @@ -import SwiftUI +import AVFoundation import Cocoa import Combine -import AVFoundation +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 + @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) - } + 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 { - +@objc(CapturaAppDelegate) class CapturaAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate +{ + @Environment(\.openURL) var openURL var statusItem: NSStatusItem! var captureState: CaptureState = .idle @@ -37,9 +57,14 @@ struct CapturaApp: App { 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( @@ -50,49 +75,71 @@ struct CapturaApp: App { closeWindow() fetchRemoteItems() } - + // MARK: - Setup Functions - - + private func setupStatusBar() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - + if let button = statusItem.button { - button.image = NSImage(systemSymbolName: "rectangle.dashed.badge.record", accessibilityDescription: "Captura") + if let image = NSImage(named: "MenuBar/Idle") { + image.isTemplate = true + image.size = NSSize(width: 18, height: 18) + button.image = image + } } - + 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( + 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: "") + 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( + 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: "")) + + 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() @@ -100,20 +147,24 @@ struct CapturaApp: App { } // MARK: - URL Event Handler - + func application(_ application: NSApplication, open urls: [URL]) { - if (CapturaSettings.shouldAllowURLAutomation) { + 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 - ]) + 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) } } @@ -121,44 +172,52 @@ struct CapturaApp: App { } 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() + 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 - + func menuWillOpen(_ menu: NSMenu) { if captureState != .idle { - menu.cancelTracking() + menu.cancelTrackingWithoutAnimation() + if captureState == .selectingArea { + NotificationCenter.default.post(name: .startRecording, object: nil, userInfo: nil) + return + } 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() + 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") { + 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 { @@ -168,15 +227,15 @@ struct CapturaApp: App { } } } - + @objc private func onQuit() { NSApplication.shared.terminate(self) } - + // MARK: - App State Event Listeners - + @objc func didReceiveNotification(_ notification: Notification) { - switch(notification.name) { + switch notification.name { case .startAreaSelection: startAreaSelection() case .startRecording: @@ -210,7 +269,7 @@ struct CapturaApp: App { } } case .reloadConfiguration: - reloadConfiguration() + reloadConfiguration() case .setCaptureSessionConfiguration: if let userInfo = notification.userInfo { if let config = userInfo["config"] as? RecordAction { @@ -226,12 +285,12 @@ struct CapturaApp: App { 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) @@ -253,25 +312,26 @@ struct CapturaApp: App { } } } - + func startRecording() { captureState = .recording updateImage() outputFile = nil - images = []; + 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!) - + DispatchQueue.main.asyncAfter( + deadline: .now() + Double(captureSessionConfiguration.maxLength), execute: stopTimer!) + outputFile = CapturaFile() if captureSessionConfiguration.shouldSaveMp4 { captureSession.startRecording(to: outputFile!.mp4URL) @@ -284,16 +344,17 @@ struct CapturaApp: App { } 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) + await GifRenderer.render( + self.images, at: self.captureSessionConfiguration.frameRate, to: outputFile.gifURL) } } let wasSuccessful = await self.uploadOrCopy() @@ -304,7 +365,7 @@ struct CapturaApp: App { } } } - + func finalizeRecording() { captureState = .uploaded updateImage() @@ -312,27 +373,32 @@ struct CapturaApp: App { 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)) { + + 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) { + 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() @@ -344,41 +410,42 @@ struct CapturaApp: App { 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 - - + private func requestPermissionToRecord() { showPopoverWithMessage("Please grant Captura permission to record") - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") { + 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) @@ -388,27 +455,36 @@ struct CapturaApp: App { } } } - + private func updateImage() { if let button = statusItem.button { - let image: String = switch captureState { - case .idle: - "rectangle.dashed.badge.record" - case .selectingArea: - "circle.rectangle.dashed" - case .recording: - "checkmark.rectangle" - case .uploading: - "dock.arrow.up.rectangle" - case .uploaded: - "checkmark.rectangle.fill" - case .error: - "xmark.rectangle.fill" + let image: String = + switch captureState { + case .idle: + "MenuBar/Idle" + case .selectingArea: + if recordingWindow?.recordingContentView.box != nil { + "MenuBar/Ready to Record" + } else { + "MenuBar/Selecting" + } + case .recording: + "MenuBar/Stop Frame 1" + case .uploading: + "MenuBar/Upload Frame 1" + case .uploaded: + "MenuBar/OK" + case .error: + "MenuBar/ERR" + } + if let image = NSImage(named: image) { + image.isTemplate = true + image.size = NSSize(width: 18, height: 18) + button.image = image } - button.image = NSImage(systemSymbolName: image, accessibilityDescription: "Captura") } } - + private func stop() { stopTimer?.cancel() captureSession?.stopRunning() @@ -417,7 +493,7 @@ struct CapturaApp: App { recordingWindow?.close() recordingWindow = nil } - + private func uploadOrCopy() async -> Bool { if captureSessionConfiguration.shouldUseBackend { let result = await uploadToBackend() @@ -430,10 +506,12 @@ struct CapturaApp: App { 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 { + 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) @@ -441,10 +519,12 @@ struct CapturaApp: App { } } } - + private func uploadToBackend() async -> Bool { let contentType = captureSessionConfiguration.shouldUploadGif ? "image/gif" : "video/mp4" - if let url = captureSessionConfiguration.shouldUploadGif ? outputFile?.gifURL : outputFile?.mp4URL { + 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) @@ -452,10 +532,10 @@ struct CapturaApp: App { 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) @@ -469,7 +549,7 @@ struct CapturaApp: App { } return false } - + private func createRemoteFile(_ url: URL) { let viewContext = PersistenceController.shared.container.viewContext let remoteFile = CapturaRemoteFile(context: viewContext) @@ -480,11 +560,11 @@ struct CapturaApp: App { 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) + try? FileManager.default.removeItem(at: url) } } if captureSessionConfiguration.shouldSaveMp4 {