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 { 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) } } } } 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 func menuWillOpen(_ menu: NSMenu) { if captureState != .idle { 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() } 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 ?? [] } // 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) } } } 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) } } 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 {} } } } 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) } } } }