-import SwiftUI
-import SwiftData
+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
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(
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")
+ 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(
+ 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()
}
// 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):
- CapturaSettings.apply(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)
}
}
} 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 {
}
}
}
-
+
@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:
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:
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)
}
}
}
-
+
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)
}
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()
}
}
}
-
+
func finalizeRecording() {
captureState = .uploaded
updateImage()
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()
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<CapturaRemoteFile>(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)
}
}
}
-
+
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"
- }
- button.image = NSImage(systemSymbolName: image, accessibilityDescription: "Captura")
+ 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()
recordingWindow?.close()
recordingWindow = nil
}
-
+
private func uploadOrCopy() async -> Bool {
if captureSessionConfiguration.shouldUseBackend {
let result = await uploadToBackend()
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)
}
}
}
-
+
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)
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)
}
return false
}
-
+
private func createRemoteFile(_ url: URL) {
let viewContext = PersistenceController.shared.container.viewContext
let remoteFile = CapturaRemoteFile(context: viewContext)
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 {