chore: adiciona .gitignore e commit.command
This commit is contained in:
+380
@@ -0,0 +1,380 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user