From dbb84885d285e94eabc28fd2f10e0a0a6c54273d Mon Sep 17 00:00:00 2001 From: lzhgus Date: Mon, 13 Apr 2026 21:48:50 -0700 Subject: [PATCH 1/2] Preserve history media actions and streamline screenshot cancellation History downloads and clipboard actions assumed every history entry was a PNG, so GIF and video items were mislabeled on save and copied in an unusable form. The screenshot overlay also required Esc to cancel, which interrupted mouse-only capture flow. This change derives history actions from media type and adds right-click cancel/clear behavior directly in the capture overlay. Constraint: History recordings are stored as temporary source assets, so user-facing save actions must emit shareable media types Constraint: Keep screenshot cancellation localized to overlay input handling without changing coordinator ownership Rejected: Save/copy recordings as raw .mov only | does not match the user-visible recording export format Rejected: Add the optional settings toggle now | broader settings/UI scope than the requested fix bundle Confidence: high Scope-risk: moderate Reversibility: clean Directive: History copy/save behavior must branch by capture mode or file type; do not assume every history asset is a PNG Tested: swift test --package-path Packages/SharedKit; xcodebuild -project Capso.xcodeproj -scheme Capso -configuration Debug build Not-tested: Manual clipboard paste/save validation for GIF and video entries; manual right-click overlay interaction on multi-display setups Related: #41 --- App/Sources/Capture/CaptureOverlayView.swift | 29 ++++- App/Sources/History/HistoryCoordinator.swift | 116 ++++++++++++++++-- .../SharedKit/Utilities/FileNaming.swift | 33 +++++ .../SharedKitTests/AppSettingsTests.swift | 26 ++++ 4 files changed, 195 insertions(+), 9 deletions(-) diff --git a/App/Sources/Capture/CaptureOverlayView.swift b/App/Sources/Capture/CaptureOverlayView.swift index 0e06fc3..761ec78 100644 --- a/App/Sources/Capture/CaptureOverlayView.swift +++ b/App/Sources/Capture/CaptureOverlayView.swift @@ -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?() + } } diff --git a/App/Sources/History/HistoryCoordinator.swift b/App/Sources/History/HistoryCoordinator.swift index 3187d14..852a7dc 100644 --- a/App/Sources/History/HistoryCoordinator.swift +++ b/App/Sources/History/HistoryCoordinator.swift @@ -2,9 +2,10 @@ import AppKit import AVFoundation import Observation -import SharedKit import CaptureKit +import ExportKit import HistoryKit +import SharedKit @MainActor @Observable @@ -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)") + } + } } } @@ -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 diff --git a/Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift b/Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift index 8067053..30a8ccc 100644 --- a/Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift +++ b/Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift @@ -1,5 +1,6 @@ // Packages/SharedKit/Sources/SharedKit/Utilities/FileNaming.swift import Foundation +import UniformTypeIdentifiers public enum CaptureType: Sendable { case screenshot @@ -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 { diff --git a/Packages/SharedKit/Tests/SharedKitTests/AppSettingsTests.swift b/Packages/SharedKit/Tests/SharedKitTests/AppSettingsTests.swift index c36da58..4510b61 100644 --- a/Packages/SharedKit/Tests/SharedKitTests/AppSettingsTests.swift +++ b/Packages/SharedKit/Tests/SharedKitTests/AppSettingsTests.swift @@ -40,4 +40,30 @@ struct AppSettingsTests { let settings = AppSettings() #expect(settings.isProUnlocked == false) } + + @Test("File formats map common extensions") + func fileFormatExtensionMapping() { + #expect(FileFormat(pathExtension: "png") == .png) + #expect(FileFormat(pathExtension: "jpg") == .jpeg) + #expect(FileFormat(pathExtension: "jpeg") == .jpeg) + #expect(FileFormat(pathExtension: "gif") == .gif) + #expect(FileFormat(pathExtension: "mp4") == .mp4) + #expect(FileFormat(pathExtension: "mov") == .mov) + #expect(FileFormat(pathExtension: "webm") == nil) + } + + @Test("Generated file names preserve the requested extension") + func generatedFileNamesUseFormatExtension() { + let date = Date(timeIntervalSince1970: 0) + + #expect( + FileNaming.generateFileName(for: .screenshot, format: .png, date: date).hasSuffix(".png") + ) + #expect( + FileNaming.generateFileName(for: .recording, format: .gif, date: date).hasSuffix(".gif") + ) + #expect( + FileNaming.generateFileName(for: .recording, format: .mov, date: date).hasSuffix(".mov") + ) + } } From 122ded5727d42206761079af8c8a5462fbac08a4 Mon Sep 17 00:00:00 2001 From: lzhgus Date: Mon, 13 Apr 2026 22:01:34 -0700 Subject: [PATCH 2/2] Ensure copied recordings use the selected output format The preview Copy action wrote the raw temporary .mov file to the pasteboard, so GIF recordings pasted as mov files even though Save exported the requested format. This change routes Copy through the same export pipeline, writes a clipboard-ready GIF/MP4 file, and surfaces progress/failure feedback in the preview UI. Constraint: Preview copy must preserve the selected recording format without breaking the existing export/save flow Constraint: Clipboard file URLs must reference real files long enough for paste targets to consume them Rejected: Write the temporary .mov directly for all copy actions | violates the user's selected GIF/MP4 output format Rejected: Convert only GIF copy and leave video copy as .mov | inconsistent preview behavior between copy and save actions Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep preview Copy and Save aligned on output format; do not bypass export for formats that require conversion Tested: xcodebuild -project Capso.xcodeproj -scheme Capso -configuration Debug build Not-tested: Manual paste validation into Finder/Slack/Notes for copied GIF/MP4 files Related: PR #43 --- .../Recording/RecordingCoordinator.swift | 85 ++++++++++++++++--- .../Recording/RecordingPreviewView.swift | 3 +- 2 files changed, 74 insertions(+), 14 deletions(-) diff --git a/App/Sources/Recording/RecordingCoordinator.swift b/App/Sources/Recording/RecordingCoordinator.swift index 5c0332a..8d3f3fb 100644 --- a/App/Sources/Recording/RecordingCoordinator.swift +++ b/App/Sources/Recording/RecordingCoordinator.swift @@ -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, @@ -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 @@ -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() @@ -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 @@ -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 @@ -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() { diff --git a/App/Sources/Recording/RecordingPreviewView.swift b/App/Sources/Recording/RecordingPreviewView.swift index b00fa26..77c5004 100644 --- a/App/Sources/Recording/RecordingPreviewView.swift +++ b/App/Sources/Recording/RecordingPreviewView.swift @@ -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 { @@ -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()