Skip to content
Draft
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
21 changes: 19 additions & 2 deletions speaktype/Controllers/MiniRecorderWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ class MiniRecorderWindowController: NSObject {
private var panel: NSPanel?
private var hostingController: NSHostingController<AnyView>?
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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
65 changes: 65 additions & 0 deletions speaktype/Services/ClipboardService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions speaktype/Views/Screens/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
26 changes: 26 additions & 0 deletions speaktypeTests/ClipboardServiceTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import XCTest
import Cocoa
@testable import speaktype

final class ClipboardServiceTests: XCTestCase {
Expand All @@ -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.
Expand Down