diff --git a/Sources/SstCore/PassthroughCleaner.swift b/Sources/SstCore/PassthroughCleaner.swift new file mode 100644 index 0000000..46ec88f --- /dev/null +++ b/Sources/SstCore/PassthroughCleaner.swift @@ -0,0 +1,7 @@ +public struct PassthroughCleaner: Cleaner { + public init() {} + + public func clean(_ text: String, context: CleaningContext) async throws -> String { + text + } +} diff --git a/Sources/SstCore/SystemPasteboard.swift b/Sources/SstCore/SystemPasteboard.swift new file mode 100644 index 0000000..629ccbf --- /dev/null +++ b/Sources/SstCore/SystemPasteboard.swift @@ -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) + } +} diff --git a/Sources/SstCore/TextInserter.swift b/Sources/SstCore/TextInserter.swift new file mode 100644 index 0000000..52acdee --- /dev/null +++ b/Sources/SstCore/TextInserter.swift @@ -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) + } +} diff --git a/Tests/SstCoreTests/TextInserterTests.swift b/Tests/SstCoreTests/TextInserterTests.swift new file mode 100644 index 0000000..9bf95bc --- /dev/null +++ b/Tests/SstCoreTests/TextInserterTests.swift @@ -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") + } +}