diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift index 85e27fe..d64dbd8 100644 --- a/speaktype/Controllers/MiniRecorderWindowController.swift +++ b/speaktype/Controllers/MiniRecorderWindowController.swift @@ -5,6 +5,9 @@ class MiniRecorderWindowController: NSObject { private var panel: NSPanel? private var hostingController: NSHostingController? private var lastActiveApp: NSRunningApplication? + private var shouldRestoreClipboardAfterAutoPaste: Bool { + UserDefaults.standard.object(forKey: "restoreClipboardAfterAutoPaste") as? Bool ?? false + } // Start recording - show panel and begin recording func startRecording() { @@ -108,8 +111,14 @@ class MiniRecorderWindowController: NSObject { private func handleCommit(text: String) { Task { - // 1. Copy to clipboard - ClipboardService.shared.copy(text: text) + // 1. Copy to clipboard for manual paste, or snapshot it first if the user wants it restored. + let previousClipboard: ClipboardService.ClipboardSnapshot? + if shouldRestoreClipboardAfterAutoPaste { + previousClipboard = ClipboardService.shared.copyForTemporaryPaste(text: text) + } else { + previousClipboard = nil + ClipboardService.shared.copy(text: text) + } // 2. Close panel await MainActor.run { @@ -142,6 +151,14 @@ class MiniRecorderWindowController: NSObject { await MainActor.run { ClipboardService.shared.paste() } + + guard let previousClipboard else { return } + + try? await Task.sleep(nanoseconds: 350_000_000) + + await MainActor.run { + ClipboardService.shared.restore(previousClipboard, ifCurrentStringMatches: text) + } } } diff --git a/speaktype/Services/ClipboardService.swift b/speaktype/Services/ClipboardService.swift index e5033eb..7f9c500 100644 --- a/speaktype/Services/ClipboardService.swift +++ b/speaktype/Services/ClipboardService.swift @@ -4,6 +4,14 @@ import Cocoa class ClipboardService { static let shared = ClipboardService() + struct ClipboardSnapshot { + fileprivate let items: [ClipboardItemSnapshot] + } + + fileprivate struct ClipboardItemSnapshot { + let dataByType: [NSPasteboard.PasteboardType: Data] + } + // Dependency injection for license checking private var licenseManager: LicenseManager { return LicenseManager.shared @@ -27,12 +35,69 @@ class ClipboardService { } } + @discardableResult + func copyForTemporaryPaste(text: String) -> ClipboardSnapshot { + let snapshot = currentSnapshot() + copy(text: text) + return snapshot + } + + func restore(_ snapshot: ClipboardSnapshot, ifCurrentStringMatches expectedText: String) { + let pasteboard = NSPasteboard.general + let expectedFinalText = wrapTextIfNeeded(expectedText) + + guard pasteboard.string(forType: .string) == expectedFinalText else { + print("Skipping clipboard restore because pasteboard changed after paste") + return + } + + restore(snapshot) + } + // Wrap text with promotional message for free users private func wrapTextIfNeeded(_ text: String) -> String { // License check disabled - always allow unwrapped text return text } + private func currentSnapshot() -> ClipboardSnapshot { + let pasteboard = NSPasteboard.general + let items: [ClipboardItemSnapshot] = pasteboard.pasteboardItems?.map { item in + var dataByType: [NSPasteboard.PasteboardType: Data] = [:] + + for type in item.types { + if let data = item.data(forType: type) { + dataByType[type] = data + } + } + + return ClipboardItemSnapshot(dataByType: dataByType) + } ?? [] + + return ClipboardSnapshot(items: items) + } + + private func restore(_ snapshot: ClipboardSnapshot) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + guard !snapshot.items.isEmpty else { + print("Restored empty clipboard") + return + } + + let restoredItems = snapshot.items.map { snapshotItem in + let item = NSPasteboardItem() + for (type, data) in snapshotItem.dataByType { + item.setData(data, forType: type) + } + return item + } + + pasteboard.writeObjects(restoredItems) + print("Restored previous clipboard contents") + } + // Paste content (Simulate Cmd+V) func paste() { // Create a concurrent task to avoid blocking main thread if needed, diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index 256dd22..83f999c 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -89,6 +89,8 @@ struct GeneralSettingsTab: View { @AppStorage("autoUpdate") private var autoUpdate = true @AppStorage("selectedHotkey") private var selectedHotkey: HotkeyOption = .fn @AppStorage("recordingMode") private var recordingMode: Int = 0 // 0: Hold to record, 1: Toggle + @AppStorage("restoreClipboardAfterAutoPaste") private var restoreClipboardAfterAutoPaste = + false @AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true @AppStorage("transcriptionLanguage") private var transcriptionLanguage: String = "auto" @AppStorage("recentTranscriptionLanguages") private var recentLanguagesString: String = "" @@ -207,6 +209,25 @@ struct GeneralSettingsTab: View { Toggle("", isOn: $showMenuBarIcon) .labelsHidden() } + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Restore clipboard after auto-paste") + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + Spacer() + Toggle("", isOn: $restoreClipboardAfterAutoPaste) + .labelsHidden() + } + + Text( + restoreClipboardAfterAutoPaste + ? "After SpeakType pastes into the active app, it restores whatever was already on your clipboard." + : "After SpeakType pastes into the active app, the transcript stays on your clipboard for manual pasting." + ) + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + } } } diff --git a/speaktypeTests/ClipboardServiceTests.swift b/speaktypeTests/ClipboardServiceTests.swift index f0bdd4d..4708fe5 100644 --- a/speaktypeTests/ClipboardServiceTests.swift +++ b/speaktypeTests/ClipboardServiceTests.swift @@ -1,4 +1,5 @@ import XCTest +import Cocoa @testable import speaktype final class ClipboardServiceTests: XCTestCase { @@ -12,6 +13,31 @@ final class ClipboardServiceTests: XCTestCase { XCTAssertEqual(copied, text, "Clipboard content should match copied text") } + + func testTemporaryPasteCanRestorePreviousClipboard() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString("https://example.com/original", forType: .string) + + let snapshot = ClipboardService.shared.copyForTemporaryPaste(text: "Dictated text") + XCTAssertEqual(pasteboard.string(forType: .string), "Dictated text") + + ClipboardService.shared.restore(snapshot, ifCurrentStringMatches: "Dictated text") + XCTAssertEqual(pasteboard.string(forType: .string), "https://example.com/original") + } + + func testRestoreDoesNotOverwriteClipboardChangedAfterPaste() { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString("Original clipboard", forType: .string) + + let snapshot = ClipboardService.shared.copyForTemporaryPaste(text: "Dictated text") + pasteboard.clearContents() + pasteboard.setString("User copied something else", forType: .string) + + ClipboardService.shared.restore(snapshot, ifCurrentStringMatches: "Dictated text") + XCTAssertEqual(pasteboard.string(forType: .string), "User copied something else") + } // Testing paste() is difficult in unit tests as it requires active application focus and AX permissions. // We primarily verify the write operation here.