Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions App/Sources/Capture/CaptureOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -412,14 +412,39 @@ final class CaptureOverlayView: NSView {
needsDisplay = true
}

override func rightMouseDown(with event: NSEvent) {
switch mode {
case .area:
if isDragging {
cancelCurrentSelection()
} else {
cancelOverlay()
}

case .windowSelection:
cancelOverlay()
}
}

override func keyDown(with event: NSEvent) {
if event.keyCode == 53 { // ESC
NSCursor.unhide()
onCancel?()
cancelOverlay()
}
}

override var acceptsFirstResponder: Bool { true }

override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

private func cancelCurrentSelection() {
isDragging = false
dragStart = currentMouseLocation
dragEnd = currentMouseLocation
needsDisplay = true
}

private func cancelOverlay() {
restoreCursorIfNeeded()
onCancel?()
}
}
116 changes: 109 additions & 7 deletions App/Sources/History/HistoryCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
import AppKit
import AVFoundation
import Observation
import SharedKit
import CaptureKit
import ExportKit
import HistoryKit
import SharedKit

@MainActor
@Observable
Expand Down Expand Up @@ -226,20 +227,48 @@ final class HistoryCoordinator {
}

func copyToClipboard(_ entry: HistoryEntry) {
guard let image = loadFullImage(for: entry) else { return }
guard let sourceURL = fullImageURL(for: entry) else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
pasteboard.writeObjects([nsImage])

switch entry.captureMode {
case .recording, .gif:
pasteboard.writeObjects([sourceURL as NSURL])

case .area, .fullscreen, .window:
guard let nsImage = NSImage(contentsOf: sourceURL) else { return }
pasteboard.writeObjects([nsImage])
}
}

func saveToFile(_ entry: HistoryEntry) {
guard let sourceURL = fullImageURL(for: entry) else { return }
let fileFormat = preferredFileFormat(for: entry, sourceURL: sourceURL)
let captureType = preferredCaptureType(for: entry)

let panel = NSSavePanel()
panel.nameFieldStringValue = "Capso Screenshot.png"
panel.allowedContentTypes = [.png]
panel.nameFieldStringValue = FileNaming.generateFileName(
for: captureType,
format: fileFormat,
date: entry.createdAt
)
panel.allowedContentTypes = [fileFormat.contentType]
panel.canCreateDirectories = true

if panel.runModal() == .OK, let destURL = panel.url {
try? FileManager.default.copyItem(at: sourceURL, to: destURL)
let exportQuality = settings.exportQuality
Task.detached(priority: .utility) {
do {
try await Self.writeHistoryEntry(
from: sourceURL,
to: destURL,
as: fileFormat,
exportQuality: exportQuality
)
} catch {
print("Failed to save history entry to file: \(error)")
}
}
}
}

Expand All @@ -248,6 +277,79 @@ final class HistoryCoordinator {
NSWorkspace.shared.activateFileViewerSelecting([url])
}

private func preferredCaptureType(for entry: HistoryEntry) -> CaptureType {
switch entry.captureMode {
case .recording, .gif:
return .recording
case .area, .fullscreen, .window:
return .screenshot
}
}

private func preferredFileFormat(for entry: HistoryEntry, sourceURL: URL) -> FileFormat {
switch entry.captureMode {
case .gif:
return .gif
case .recording:
return .mp4
case .area, .fullscreen, .window:
return FileFormat(pathExtension: sourceURL.pathExtension) ?? .png
}
}

private static func writeHistoryEntry(
from sourceURL: URL,
to destinationURL: URL,
as fileFormat: FileFormat,
exportQuality: ExportQuality
) async throws {
switch fileFormat {
case .gif:
if FileFormat(pathExtension: sourceURL.pathExtension) == .gif {
try copyItemReplacingExisting(from: sourceURL, to: destinationURL)
} else {
try await exportVideo(from: sourceURL, to: destinationURL, format: .gif, exportQuality: exportQuality)
}
case .mp4:
if FileFormat(pathExtension: sourceURL.pathExtension) == .mp4 {
try copyItemReplacingExisting(from: sourceURL, to: destinationURL)
} else {
try await exportVideo(from: sourceURL, to: destinationURL, format: .mp4, exportQuality: exportQuality)
}
case .png, .jpeg, .mov:
try copyItemReplacingExisting(from: sourceURL, to: destinationURL)
}
}

private static func exportVideo(
from sourceURL: URL,
to destinationURL: URL,
format: ExportFormat,
exportQuality: ExportQuality
) async throws {
try removeExistingItemIfNeeded(at: destinationURL)
_ = try await VideoExporter.export(
source: sourceURL,
options: ExportOptions(
format: format,
quality: exportQuality,
destination: destinationURL
)
)
}

private static func copyItemReplacingExisting(from sourceURL: URL, to destinationURL: URL) throws {
try removeExistingItemIfNeeded(at: destinationURL)
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
}

private static func removeExistingItemIfNeeded(at url: URL) throws {
let fileManager = FileManager.default
if fileManager.fileExists(atPath: url.path) {
try fileManager.removeItem(at: url)
}
}

func runCleanup() {
guard let store else { return }
let retention = HistoryRetention(rawValue: settings.historyRetention) ?? .oneMonth
Expand Down
85 changes: 72 additions & 13 deletions App/Sources/Recording/RecordingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -442,15 +442,22 @@ final class RecordingCoordinator {
private func exportRecording(
_ tempURL: URL,
format: RecordingKit.RecordingFormat,
destinationOverride: URL? = nil,
deleteSourceOnSuccess: Bool = true,
progress: (@Sendable (Double) -> Void)? = nil
) async -> URL? {
let exportDir = settings.exportLocation
try? FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)

let exportFormat: ExportFormat = format == .gif ? .gif : .mp4
let fileFormat: FileFormat = format == .gif ? .gif : .mp4
let fileName = FileNaming.generateFileName(for: .recording, format: fileFormat)
let destURL = exportDir.appendingPathComponent(fileName)
let destURL: URL
if let destinationOverride {
destURL = destinationOverride
} else {
let exportDir = settings.exportLocation
let fileName = FileNaming.generateFileName(for: .recording, format: fileFormat)
destURL = exportDir.appendingPathComponent(fileName)
}
let exportDir = destURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(at: exportDir, withIntermediateDirectories: true)

let options = ExportOptions(
format: exportFormat,
Expand All @@ -460,8 +467,10 @@ final class RecordingCoordinator {

do {
let result = try await VideoExporter.export(source: tempURL, options: options, progress: progress)
// Clean up temp file after successful export
try? FileManager.default.removeItem(at: tempURL)
if deleteSourceOnSuccess {
// Clean up temp file after successful export
try? FileManager.default.removeItem(at: tempURL)
}
return result
} catch {
// Use NSLog so the error is visible in Console.app — `print` only
Expand All @@ -483,6 +492,16 @@ final class RecordingCoordinator {
alert.runModal()
}

private func showRecordingCopyFailureAlert(format: RecordingKit.RecordingFormat) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = String(localized: "Couldn't copy recording")
let kind = format == .gif ? String(localized: "GIF") : String(localized: "video")
alert.informativeText = String(localized: "Copying the \(kind) to the clipboard failed. The recording is still available in the preview — close this dialog and try Copy again, or use Save.")
alert.addButton(withTitle: String(localized: "OK"))
alert.runModal()
}

private func showRecordingPreview(thumbnail: NSImage?, duration: String, fileSize: String,
tempURL: URL, format: RecordingKit.RecordingFormat) {
recordingPreviewWindow?.close()
Expand All @@ -494,15 +513,35 @@ final class RecordingCoordinator {
state: state, settings: settings
)

window.onCopy = { [weak self] in
window.onCopy = { [weak self, weak window] in
// Ignore Copy if a save is already running — same reason we
// disable the buttons in the view (avoid double-write races).
guard !state.isSaving else { return }
// Copy temp file to clipboard
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([tempURL as NSURL])
self?.recordingPreviewWindow?.close()
self?.recordingPreviewWindow = nil
guard let self else { return }
state.isSaving = true
state.saveProgress = 0
state.progressLabel = String(localized: "Copying…")
window?.cancelAutoDismissForSave()

Task { @MainActor in
let clipboardURL = await self.exportRecordingToClipboard(tempURL, format: format) { progress in
Task { @MainActor in
state.saveProgress = progress
}
}

if let clipboardURL {
NSPasteboard.general.clearContents()
NSPasteboard.general.writeObjects([clipboardURL as NSURL])
self.recordingPreviewWindow?.close()
self.recordingPreviewWindow = nil
} else {
state.isSaving = false
state.saveProgress = 0
state.progressLabel = String(localized: "Saving…")
self.showRecordingCopyFailureAlert(format: format)
}
}
}

window.onSave = { [weak self, weak window] in
Expand All @@ -513,6 +552,7 @@ final class RecordingCoordinator {
guard !state.isSaving else { return }
state.isSaving = true
state.saveProgress = 0
state.progressLabel = String(localized: "Saving…")
window?.cancelAutoDismissForSave()

Task { @MainActor in
Expand Down Expand Up @@ -549,6 +589,25 @@ final class RecordingCoordinator {
recordingPreviewWindow = window
}

private func exportRecordingToClipboard(
_ tempURL: URL,
format: RecordingKit.RecordingFormat,
progress: (@Sendable (Double) -> Void)? = nil
) async -> URL? {
let fileFormat: FileFormat = format == .gif ? .gif : .mp4
let clipboardDir = FileManager.default.temporaryDirectory
.appendingPathComponent("capso-clipboard-exports", isDirectory: true)
let destinationURL = clipboardDir
.appendingPathComponent("capso_clipboard_\(UUID().uuidString).\(FileNaming.fileExtension(for: fileFormat))")

return await exportRecording(
tempURL,
format: format,
destinationOverride: destinationURL,
progress: progress
)
}

// MARK: - UI Helpers

private func dismissOverlay() {
Expand Down
3 changes: 2 additions & 1 deletion App/Sources/Recording/RecordingPreviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Observation
final class RecordingPreviewState {
var isSaving: Bool = false
var saveProgress: Double = 0
var progressLabel: String = String(localized: "Saving…")
}

struct RecordingPreviewView: View {
Expand Down Expand Up @@ -104,7 +105,7 @@ struct RecordingPreviewView: View {
.progressViewStyle(.linear)
.controlSize(.small)
.tint(.accentColor)
Text("Saving… \(Int(state.saveProgress * 100))%")
Text("\(state.progressLabel) \(Int(state.saveProgress * 100))%")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.monospacedDigit()
Expand Down
33 changes: 33 additions & 0 deletions Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift
import Foundation
import UniformTypeIdentifiers

public enum CaptureType: Sendable {
case screenshot
Expand All @@ -12,6 +13,38 @@ public enum FileFormat: String, Sendable {
case mp4
case gif
case mov

public init?(pathExtension: String) {
switch pathExtension.lowercased() {
case "png":
self = .png
case "jpg", "jpeg":
self = .jpeg
case "mp4":
self = .mp4
case "gif":
self = .gif
case "mov":
self = .mov
default:
return nil
}
}

public var contentType: UTType {
switch self {
case .png:
return .png
case .jpeg:
return .jpeg
case .mp4:
return .mpeg4Movie
case .gif:
return .gif
case .mov:
return .quickTimeMovie
}
}
}

public enum FileNaming {
Expand Down
Loading
Loading