feat: add TextInserter with clipboard restore and PassthroughCleaner

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 09:42:03 +02:00
co-authored by Claude Sonnet 5.5
parent 6ab94cfdd0
commit 7d24f3e874
4 changed files with 174 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
public struct PassthroughCleaner: Cleaner {
public init() {}
public func clean(_ text: String, context: CleaningContext) async throws -> String {
text
}
}
+65
View File
@@ -0,0 +1,65 @@
import AppKit
import ApplicationServices
public struct SystemPasteboard: PasteboardProviding {
public init() {}
public func readItems() -> [PasteboardItem] {
(NSPasteboard.general.pasteboardItems ?? []).map { item in
var dict: PasteboardItem = [:]
for type in item.types {
if let data = item.data(forType: type) { dict[type.rawValue] = data }
}
return dict
}
}
public func writeString(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
public func writeItems(_ items: [PasteboardItem]) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
let restored = items.map { dict -> NSPasteboardItem in
let item = NSPasteboardItem()
for (type, data) in dict { item.setData(data, forType: NSPasteboard.PasteboardType(type)) }
return item
}
if !restored.isEmpty { pasteboard.writeObjects(restored) }
}
}
public struct CGEventPaster: KeystrokePosting {
public enum PasteError: Error, LocalizedError {
case accessibilityDenied
case eventCreationFailed
public var errorDescription: String? {
switch self {
case .accessibilityDenied:
"Geen Accessibility-rechten: tekst staat op het klembord, plak zelf met ⌘V."
case .eventCreationFailed:
"Kon het plak-event niet aanmaken: tekst staat op het klembord."
}
}
}
public init() {}
public func postPaste() throws {
guard AXIsProcessTrusted() else { throw PasteError.accessibilityDenied }
let source = CGEventSource(stateID: .combinedSessionState)
let vKey: CGKeyCode = 0x09
guard
let down = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true),
let up = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false)
else { throw PasteError.eventCreationFailed }
down.flags = .maskCommand
up.flags = .maskCommand
down.post(tap: .cgAnnotatedSessionEventTap)
up.post(tap: .cgAnnotatedSessionEventTap)
}
}
+39
View File
@@ -0,0 +1,39 @@
import Foundation
public typealias PasteboardItem = [String: Data]
public protocol PasteboardProviding {
func readItems() -> [PasteboardItem]
func writeString(_ text: String)
func writeItems(_ items: [PasteboardItem])
}
public protocol KeystrokePosting {
func postPaste() throws
}
public struct TextInserter: TextInserting {
private let pasteboard: PasteboardProviding
private let paster: KeystrokePosting
private let restoreDelay: Duration
public init(
pasteboard: PasteboardProviding,
paster: KeystrokePosting,
restoreDelay: Duration = .milliseconds(150)
) {
self.pasteboard = pasteboard
self.paster = paster
self.restoreDelay = restoreDelay
}
public func insert(_ text: String) async throws {
let saved = pasteboard.readItems()
pasteboard.writeString(text)
// On failure the text deliberately stays on the clipboard.
try paster.postPaste()
// Give the target app time to read the clipboard before restoring it.
try await Task.sleep(for: restoreDelay)
pasteboard.writeItems(saved)
}
}
@@ -0,0 +1,63 @@
import Foundation
import Testing
@testable import SstCore
final class FakePasteboard: PasteboardProviding {
var items: [PasteboardItem] = []
func readItems() -> [PasteboardItem] { items }
func writeString(_ text: String) {
items = [["public.utf8-plain-text": Data(text.utf8)]]
}
func writeItems(_ items: [PasteboardItem]) { self.items = items }
}
final class FakePaster: KeystrokePosting {
var shouldFail = false
var onPaste: (() -> Void)?
private(set) var pasteCount = 0
func postPaste() throws {
pasteCount += 1
if shouldFail { throw TestError() }
onPaste?()
}
}
struct TextInserterTests {
let pasteboard = FakePasteboard()
let paster = FakePaster()
func makeInserter() -> TextInserter {
TextInserter(pasteboard: pasteboard, paster: paster, restoreDelay: .zero)
}
@Test func pastesTextThenRestoresPreviousClipboard() async throws {
let original: [PasteboardItem] = [["public.utf8-plain-text": Data("oud".utf8)]]
pasteboard.items = original
var seenAtPaste: Data?
paster.onPaste = { seenAtPaste = pasteboard.items.first?["public.utf8-plain-text"] }
try await makeInserter().insert("hallo")
#expect(seenAtPaste == Data("hallo".utf8))
#expect(paster.pasteCount == 1)
#expect(pasteboard.items == original)
}
@Test func failedPasteLeavesTextOnClipboard() async {
pasteboard.items = [["public.utf8-plain-text": Data("oud".utf8)]]
paster.shouldFail = true
await #expect(throws: TestError.self) {
try await makeInserter().insert("hallo")
}
#expect(pasteboard.items.first?["public.utf8-plain-text"] == Data("hallo".utf8))
}
@Test func passthroughCleanerReturnsTextUnchanged() async throws {
let result = try await PassthroughCleaner().clean("hoi", context: CleaningContext())
#expect(result == "hoi")
}
}