Files
gart/code/Hex-main/Hex/Clients/PasteboardClient.swift
T

381 lines
13 KiB
Swift
Executable File

//
// PasteboardClient.swift
// Hex
//
// Created by Kit Langton on 1/24/25.
//
import ComposableArchitecture
import Dependencies
import DependenciesMacros
import Foundation
import HexCore
import Sauce
import SwiftUI
private let pasteboardLogger = HexLog.pasteboard
@DependencyClient
struct PasteboardClient {
var paste: @Sendable (String) async -> Void
var copy: @Sendable (String) async -> Void
var sendKeyboardCommand: @Sendable (KeyboardCommand) async -> Void
}
extension PasteboardClient: DependencyKey {
static var liveValue: Self {
let live = PasteboardClientLive()
return .init(
paste: { text in
await live.paste(text: text)
},
copy: { text in
await live.copy(text: text)
},
sendKeyboardCommand: { command in
await live.sendKeyboardCommand(command)
}
)
}
}
extension DependencyValues {
var pasteboard: PasteboardClient {
get { self[PasteboardClient.self] }
set { self[PasteboardClient.self] = newValue }
}
}
struct PasteboardClientLive {
@Shared(.hexSettings) var hexSettings: HexSettings
private struct PasteboardSnapshot {
let items: [[String: Any]]
init(pasteboard: NSPasteboard) {
var saved: [[String: Any]] = []
for item in pasteboard.pasteboardItems ?? [] {
var itemDict: [String: Any] = [:]
for type in item.types {
if let data = item.data(forType: type) {
itemDict[type.rawValue] = data
}
}
saved.append(itemDict)
}
self.items = saved
}
func restore(to pasteboard: NSPasteboard) {
pasteboard.clearContents()
for itemDict in items {
let item = NSPasteboardItem()
for (type, data) in itemDict {
if let data = data as? Data {
item.setData(data, forType: NSPasteboard.PasteboardType(rawValue: type))
}
}
pasteboard.writeObjects([item])
}
}
}
@MainActor
func paste(text: String) async {
if hexSettings.useClipboardPaste {
await pasteWithClipboard(text)
} else {
simulateTypingWithAppleScript(text)
}
}
@MainActor
func copy(text: String) async {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
@MainActor
func sendKeyboardCommand(_ command: KeyboardCommand) async {
let source = CGEventSource(stateID: .combinedSessionState)
// Convert modifiers to CGEventFlags and key codes for modifier keys
var modifierKeyCodes: [CGKeyCode] = []
var flags = CGEventFlags()
for modifier in command.modifiers.sorted {
switch modifier.kind {
case .command:
flags.insert(.maskCommand)
modifierKeyCodes.append(55) // Left Cmd
case .shift:
flags.insert(.maskShift)
modifierKeyCodes.append(56) // Left Shift
case .option:
flags.insert(.maskAlternate)
modifierKeyCodes.append(58) // Left Option
case .control:
flags.insert(.maskControl)
modifierKeyCodes.append(59) // Left Control
case .fn:
flags.insert(.maskSecondaryFn)
// Fn key doesn't need explicit key down/up
}
}
// Press modifiers down
for keyCode in modifierKeyCodes {
let modDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
modDown?.post(tap: .cghidEventTap)
}
// Press main key if present
if let key = command.key {
let keyCode = Sauce.shared.keyCode(for: key)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true)
keyDown?.flags = flags
keyDown?.post(tap: .cghidEventTap)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
keyUp?.flags = flags
keyUp?.post(tap: .cghidEventTap)
}
// Release modifiers in reverse order
for keyCode in modifierKeyCodes.reversed() {
let modUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
modUp?.post(tap: .cghidEventTap)
}
pasteboardLogger.debug("Sent keyboard command: \(command.displayName)")
}
/// Pastes current clipboard content to the frontmost application
static func pasteToFrontmostApp() -> Bool {
let script = """
if application "System Events" is not running then
tell application "System Events" to launch
delay 0.1
end if
tell application "System Events"
tell process (name of first application process whose frontmost is true)
tell (menu item "Paste" of menu of menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1)
if exists then
log (get properties of it)
if enabled then
click it
return true
else
return false
end if
end if
end tell
tell (menu item "Paste" of menu "Edit" of menu bar item "Edit" of menu bar 1)
if exists then
if enabled then
click it
return true
else
return false
end if
else
return false
end if
end tell
end tell
end tell
"""
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
let result = scriptObject.executeAndReturnError(&error)
if let error = error {
pasteboardLogger.error("AppleScript paste failed: \(error)")
return false
}
return result.booleanValue
}
return false
}
@MainActor
func pasteWithClipboard(_ text: String) async {
let pasteboard = NSPasteboard.general
let snapshot = PasteboardSnapshot(pasteboard: pasteboard)
let targetChangeCount = writeAndTrackChangeCount(pasteboard: pasteboard, text: text)
_ = await waitForPasteboardCommit(targetChangeCount: targetChangeCount)
let pasteSucceeded = await performPaste(text)
// Only restore original pasteboard contents if:
// 1. Copying to clipboard is disabled AND
// 2. The paste operation succeeded
if !hexSettings.copyToClipboard && pasteSucceeded {
let savedSnapshot = snapshot
Task { @MainActor in
// Give slower apps a short window to read the plain-text entry
// before we repopulate the clipboard with the user's previous rich data.
try? await Task.sleep(for: .milliseconds(500))
pasteboard.clearContents()
savedSnapshot.restore(to: pasteboard)
}
}
// If we failed to paste AND user doesn't want clipboard retention,
// show a notification that text is available in clipboard
if !pasteSucceeded && !hexSettings.copyToClipboard {
// Keep the transcribed text in clipboard regardless of setting
pasteboardLogger.notice("Paste operation failed; text remains in clipboard as fallback.")
// TODO: Could add a notification here to inform user
// that text is available in clipboard
}
}
@MainActor
private func writeAndTrackChangeCount(pasteboard: NSPasteboard, text: String) -> Int {
let before = pasteboard.changeCount
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
let after = pasteboard.changeCount
if after == before {
// Ensure we always advance by at least one to avoid infinite waits if the system
// coalesces writes (seen on Sonoma betas with zero-length strings).
return after + 1
}
return after
}
@MainActor
private func waitForPasteboardCommit(
targetChangeCount: Int,
timeout: Duration = .milliseconds(150),
pollInterval: Duration = .milliseconds(5)
) async -> Bool {
guard targetChangeCount > NSPasteboard.general.changeCount else { return true }
let deadline = ContinuousClock.now + timeout
while ContinuousClock.now < deadline {
if NSPasteboard.general.changeCount >= targetChangeCount {
return true
}
try? await Task.sleep(for: pollInterval)
}
return false
}
// MARK: - Paste Orchestration
@MainActor
private enum PasteStrategy: CaseIterable {
case cmdV
case menuItem
case accessibility
}
@MainActor
private func performPaste(_ text: String) async -> Bool {
for strategy in PasteStrategy.allCases {
if await attemptPaste(text, using: strategy) {
return true
}
}
return false
}
@MainActor
private func attemptPaste(_ text: String, using strategy: PasteStrategy) async -> Bool {
switch strategy {
case .cmdV:
return await postCmdV(delayMs: 0)
case .menuItem:
return PasteboardClientLive.pasteToFrontmostApp()
case .accessibility:
return (try? Self.insertTextAtCursor(text)) != nil
}
}
// MARK: - Helpers
@MainActor
private func postCmdV(delayMs: Int) async -> Bool {
// Optional tiny wait before keystrokes
try? await wait(milliseconds: delayMs)
let source = CGEventSource(stateID: .combinedSessionState)
let vKey = vKeyCode()
let cmdKey: CGKeyCode = 55
let cmdDown = CGEvent(keyboardEventSource: source, virtualKey: cmdKey, keyDown: true)
let vDown = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: true)
vDown?.flags = .maskCommand
let vUp = CGEvent(keyboardEventSource: source, virtualKey: vKey, keyDown: false)
vUp?.flags = .maskCommand
let cmdUp = CGEvent(keyboardEventSource: source, virtualKey: cmdKey, keyDown: false)
cmdDown?.post(tap: .cghidEventTap)
vDown?.post(tap: .cghidEventTap)
vUp?.post(tap: .cghidEventTap)
cmdUp?.post(tap: .cghidEventTap)
return true
}
@MainActor
private func vKeyCode() -> CGKeyCode {
if Thread.isMainThread { return Sauce.shared.keyCode(for: .v) }
return DispatchQueue.main.sync { Sauce.shared.keyCode(for: .v) }
}
@MainActor
private func wait(milliseconds: Int) async throws {
try Task.checkCancellation()
try await Task.sleep(nanoseconds: UInt64(milliseconds) * 1_000_000)
}
func simulateTypingWithAppleScript(_ text: String) {
let escapedText = text.replacingOccurrences(of: "\"", with: "\\\"")
let script = NSAppleScript(source: "tell application \"System Events\" to keystroke \"\(escapedText)\"")
var error: NSDictionary?
script?.executeAndReturnError(&error)
if let error = error {
pasteboardLogger.error("Error executing AppleScript typing fallback: \(error)")
}
}
enum PasteError: Error {
case systemWideElementCreationFailed
case focusedElementNotFound
case elementDoesNotSupportTextEditing
case failedToInsertText
}
static func insertTextAtCursor(_ text: String) throws {
// Get the system-wide accessibility element
let systemWideElement = AXUIElementCreateSystemWide()
// Get the focused element
var focusedElementRef: CFTypeRef?
let axError = AXUIElementCopyAttributeValue(systemWideElement, kAXFocusedUIElementAttribute as CFString, &focusedElementRef)
guard axError == .success, let focusedElementRef = focusedElementRef else {
throw PasteError.focusedElementNotFound
}
let focusedElement = focusedElementRef as! AXUIElement
// Verify if the focused element supports text insertion
var value: CFTypeRef?
let supportsText = AXUIElementCopyAttributeValue(focusedElement, kAXValueAttribute as CFString, &value) == .success
let supportsSelectedText = AXUIElementCopyAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, &value) == .success
if !supportsText && !supportsSelectedText {
throw PasteError.elementDoesNotSupportTextEditing
}
// Insert text at cursor position by replacing selected text (or empty selection)
let insertResult = AXUIElementSetAttributeValue(focusedElement, kAXSelectedTextAttribute as CFString, text as CFTypeRef)
if insertResult != .success {
throw PasteError.failedToInsertText
}
}
}