feat: add TextInserter with clipboard restore and PassthroughCleaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
public struct PassthroughCleaner: Cleaner {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func clean(_ text: String, context: CleaningContext) async throws -> String {
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user