From 621f33d7d5455649655048d826c0cf2e58008aa9 Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:42:42 -0400 Subject: [PATCH 1/3] Replace the CGEventTap with a registered global hot key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tap sat in the path of every keystroke on the system to act on a handful of them, and needed a watchdog to survive the OS disabling it — which it could never actually do, since a tap starved by Secure Input looks identical to one that is merely idle. RegisterEventHotKey removes CopyCat from the input path entirely, but it is all-or-nothing: once a chord is claimed the OS swallows it before any app sees it, with no way to decline. The chord in question is plain ⌘V, so it is armed only across the window where CopyCat would have acted anyway — a target app frontmost, an image on the clipboard, Accessibility granted — which reproduces the old conditional behavior. Every gate input is observable outside the keystroke path, so a hung handler can only delay CopyCat's own paste. The gate is sampled up to a poll interval before a keystroke lands, so a chord can fire after it closed. That case hands the keystroke back rather than dropping it, releasing every armed chord first: the re-posted event carries the same modifiers the hot key matches on, so posting it while the chord is still claimed would feed it straight back in. Also: - Menu header now tells apart "off", "needs Accessibility", and a chord another app owns, since none of those are fixed the same way. - Local paste and the Secure Input HID sensor share one cooldown, so a single keystroke seen by both paths types the path once. - Requires Swift 6.2 for isolated deinit in hot key teardown. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- Package.swift | 2 +- README.md | 6 +- Sources/CopyCat/CarbonHotKey.swift | 397 ++++++++++++++ Sources/CopyCat/CopyCatApp.swift | 36 +- Sources/CopyCat/HotkeyBinding.swift | 33 +- Sources/CopyCat/Logger.swift | 3 +- Sources/CopyCat/PasteAttemptSensor.swift | 8 +- Sources/CopyCat/PasteCooldown.swift | 36 ++ Sources/CopyCat/PasteHandler.swift | 496 ++++++++++-------- Sources/CopyCat/SecureInput.swift | 12 +- Sources/CopyCat/SecureInputWatcher.swift | 26 +- Sources/CopyCat/StatusModel.swift | 38 +- Tests/CopyCatTests/CarbonHotKeyTests.swift | 574 +++++++++++++++++++++ Tests/CopyCatTests/PasteHandlerTests.swift | 331 ++++++++++++ 15 files changed, 1729 insertions(+), 271 deletions(-) create mode 100644 Sources/CopyCat/CarbonHotKey.swift create mode 100644 Sources/CopyCat/PasteCooldown.swift create mode 100644 Tests/CopyCatTests/CarbonHotKeyTests.swift create mode 100644 Tests/CopyCatTests/PasteHandlerTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 13bc624..ab284d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ **CLI-only** — no Xcode. All builds use `swift build` via SPM. Use `just` as the task runner. -- macOS 14.0+, Swift 6.0+ +- macOS 14.0+, Swift 6.2+ (hot key teardown uses `isolated deinit`, non-experimental only from 6.2) ```bash just dev # Kill, build, sign, install to /Applications/CopyCat Dev.app, launch diff --git a/Package.swift b/Package.swift index 4c79e10..0a7a3dd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription let package = Package( diff --git a/README.md b/README.md index 02fd8a5..53d6f19 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

macOS 14.0+ - Swift 6.0+ + Swift 6.2+ License MIT GitHub release Claudex @@ -31,7 +31,7 @@ 1. [**Download CopyCat**](https://github.com/andyhtran/CopyCat/releases/latest/download/CopyCat.dmg) 2. Open the DMG and drag the app to your Applications folder 3. Launch CopyCat (look for the paw icon in the menu bar) -4. Grant Accessibility permission when prompted — required for ⌘V interception +4. Grant Accessibility permission when prompted — CopyCat needs it to type the file path, and leaves ⌘V completely alone until it's granted 5. Copy any image, then press **⌘V** in your terminal — the file path is typed for you To paste into remote terminals over SSH, click the menu bar icon → **Settings… → General → Enable SSH paste**, then add hosts in the **SSH hosts** tab. If Tailscale is installed, peers show up for one-click add. @@ -53,7 +53,7 @@ brew update && brew install --cask copycat ### Build from source -Requires macOS 14+ (Sonoma) and Swift 6+. +Requires macOS 14+ (Sonoma) and Swift 6.2+. ```bash git clone https://github.com/andyhtran/CopyCat.git diff --git a/Sources/CopyCat/CarbonHotKey.swift b/Sources/CopyCat/CarbonHotKey.swift new file mode 100644 index 0000000..84dc97e --- /dev/null +++ b/Sources/CopyCat/CarbonHotKey.swift @@ -0,0 +1,397 @@ +import Carbon.HIToolbox +import Foundation + +/// What a registered chord does when it fires. Raw values are stamped into +/// `EventHotKeyID.id`; they only need to be stable and unique in this process. +enum HotkeyAction: UInt32, CaseIterable, Sendable { + case localPaste = 1 + case broadcast = 2 +} + +// MARK: - Registration seam + +/// Thin seam over the Carbon hot key C API. Tests substitute a fake so the +/// reconcile bookkeeping can be exercised without claiming real chords, which +/// would apply to the whole login session including the test runner's host. +@MainActor +protocol CarbonHotKeyRegistering: AnyObject { + /// Fires with the action whose chord the system matched. By the time this + /// runs the OS has already swallowed the keystroke — there is no way to + /// decline it and let the event continue to the focused app, so a caller + /// that decides not to act has to hand the keystroke back itself. + var onFire: ((HotkeyAction) -> Void)? { get set } + + func register(_ action: HotkeyAction, keyCode: UInt32, carbonModifiers: UInt32) -> OSStatus + func unregister(_ action: HotkeyAction) -> OSStatus + func unregisterAll() + /// Drops every chord *and* the process-wide event handler. Registering + /// again reinstalls the handler, so this is teardown, not a one-way door. + func invalidate() +} + +@MainActor +final class SystemCarbonHotKeyRegistrar: CarbonHotKeyRegistering { + var onFire: ((HotkeyAction) -> Void)? + + private var hotKeys: [HotkeyAction: EventHotKeyRef] = [:] + private var handler: EventHandlerRef? + private let installHandler: @MainActor (SystemCarbonHotKeyRegistrar) -> OSStatus + + /// Tags every `EventHotKeyID` we create so the handler ignores hot keys + /// registered elsewhere in the process (a framework may install its own). + fileprivate static let signature: OSType = + Array("CpCt".utf8).reduce(OSType(0)) { ($0 << 8) | OSType($1) } + + /// Reported success but produced nothing usable — no hot key ref, or a + /// handler the installer claimed to install. Recording either would leave + /// a chord claimed with nothing behind it, so both surface as failures. + private static let inconsistentStateStatus = OSStatus(paramErr) + + /// The installer is injectable because the interesting case is the failing + /// one: registration has to fail closed when no handler got installed, and + /// provoking that for real would mean claiming chords system-wide. + init(installHandler: @escaping @MainActor (SystemCarbonHotKeyRegistrar) -> OSStatus + = SystemCarbonHotKeyRegistrar.installSystemHandler) { + self.installHandler = installHandler + } + + isolated deinit { + invalidate() + } + + func register(_ action: HotkeyAction, keyCode: UInt32, carbonModifiers: UInt32) -> OSStatus { + // Fail closed. A chord registered with no handler behind it is the + // worst state available: the OS swallows the keystroke for every app + // on the system and nothing ever acts on it. + let handlerStatus = installHandlerIfNeeded() + guard handlerStatus == noErr else { return handlerStatus } + + var ref: EventHotKeyRef? + let id = EventHotKeyID(signature: Self.signature, id: action.rawValue) + let status = RegisterEventHotKey( + keyCode, + carbonModifiers, + id, + GetEventDispatcherTarget(), + 0, + &ref) + + guard status == noErr else { return status } + // Recording a registration we hold no handle for would leak the chord + // for the life of the process. + guard let ref else { return Self.inconsistentStateStatus } + hotKeys[action] = ref + return noErr + } + + func unregister(_ action: HotkeyAction) -> OSStatus { + guard let ref = hotKeys[action] else { return noErr } + let status = UnregisterEventHotKey(ref) + guard status == noErr else { + // Keep the ref. The chord is still claimed system-wide and this is + // the only handle that can ever release it; dropping it here would + // strand the chord for the life of the process. + Log.hotkey.error("UnregisterEventHotKey failed (OSStatus \(status)) — chord stays claimed, keeping its ref for a later retry") + return status + } + hotKeys.removeValue(forKey: action) + return noErr + } + + func unregisterAll() { + for action in Array(hotKeys.keys) { _ = unregister(action) } + } + + func invalidate() { + unregisterAll() + guard let handler else { return } + // If a chord above refused to release, this leaves exactly the state + // registration guards against — a claimed chord with no handler behind + // it. There is no better option: keeping the handler installed past + // deallocation makes its unretained context a use-after-free, and this + // path only runs at teardown, where the process is going away and + // taking its claims with it. + // Must outlive every chord it dispatches for, and must not outlive + // `self`: the handler's context is an unretained pointer to this + // instance, so leaving it installed past deallocation is a + // use-after-free waiting for the next matching chord. + let status = RemoveEventHandler(handler) + if status != noErr { + Log.hotkey.error("RemoveEventHandler failed (OSStatus \(status))") + } + self.handler = nil + } + + private func installHandlerIfNeeded() -> OSStatus { + guard handler == nil else { return noErr } + + let status = installHandler(self) + if status != noErr { + Log.hotkey.error("InstallEventHandler failed (OSStatus \(status)) — not claiming any chord, since a claimed chord with no handler swallows the keystroke and does nothing") + return status + } + guard handler != nil else { + Log.hotkey.error("event handler reported success but left no ref — not claiming any chord") + return Self.inconsistentStateStatus + } + return noErr + } + + static func installSystemHandler(_ registrar: SystemCarbonHotKeyRegistrar) -> OSStatus { + var spec = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed)) + + // Only key-down: CopyCat acts on the press and has no key-up behavior, + // so registering kEventHotKeyReleased would just add dispatches to drop. + return InstallEventHandler( + GetEventDispatcherTarget(), + { _, event, context in + guard let event else { return OSStatus(eventNotHandledErr) } + var id = EventHotKeyID() + let status = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &id) + guard status == noErr else { return status } + // Identify the hot key before touching `context`: the pointer + // is unretained, and resurrecting an object from it for an + // event that was never ours is exactly the case where it is + // most likely to be dead. + guard id.signature == SystemCarbonHotKeyRegistrar.signature, + let action = HotkeyAction(rawValue: id.id), + let context else { + return OSStatus(eventNotHandledErr) + } + // Carbon dispatches hot keys on the main thread; taking the + // isolation without a hop keeps the handler's latency the + // user's latency. + return MainActor.assumeIsolated { + let registrar = Unmanaged + .fromOpaque(context).takeUnretainedValue() + return registrar.dispatch(action) + } + }, + 1, + &spec, + Unmanaged.passUnretained(registrar).toOpaque(), + ®istrar.handler) + } + + private func dispatch(_ action: HotkeyAction) -> OSStatus { + onFire?(action) + return noErr + } +} + +// MARK: - Plan + +/// Which chords CopyCat should be holding right now. Pure so the arming rule — +/// the thing standing between "⌘V works everywhere" and "⌘V is dead +/// system-wide" — is directly testable. +enum HotkeyPlan { + static func desired( + frontmostBundleID: String?, + targetBundleIDs: Set, + clipboardHasImage: Bool, + accessibilityTrusted: Bool, + localPasteEnabled: Bool, + broadcastEnabled: Bool, + broadcastBinding: HotkeyBinding + ) -> [HotkeyAction: HotkeyBinding] { + // A registered chord is swallowed for every app on the system, so + // CopyCat may only hold one while it would actually act on it. + guard let frontmostBundleID, + targetBundleIDs.contains(frontmostBundleID), + clipboardHasImage else { return [:] } + + // Carbon registers chords without any TCC grant, but typing the path + // posts synthetic key events, which the OS drops for an untrusted + // process. Holding a chord in that state swallows ⌘V and produces + // nothing at all — worse than never arming, so don't arm. + guard accessibilityTrusted else { return [:] } + + var plan: [HotkeyAction: HotkeyBinding] = [:] + if localPasteEnabled { + plan[.localPaste] = .localPaste + } + if broadcastEnabled { + // The chords are allowed to collide (⌘V is a selectable broadcast + // chord). Carbon rejects a second registration of the same chord, + // so the collision is resolved here instead: broadcast wins, which + // is the precedence users already had. + plan = plan.filter { $0.value != broadcastBinding } + plan[.broadcast] = broadcastBinding + } + return plan + } +} + +// MARK: - Fire decision + +/// Why a fired chord isn't being acted on. Both are races against the arming +/// gate: a chord is armed from state sampled up to one poll interval ago, and +/// the keystroke can land after that state changed. +enum HotkeyBailReason: String, Sendable { + case frontmostNotATarget + case clipboardImageGone +} + +/// What to do with a chord the OS just handed over. `passThrough` is not "do +/// nothing": the keystroke was swallowed before any app saw it, so bailing +/// without handing it back destroys the user's ⌘V outright. +enum HotkeyFireOutcome: Equatable, Sendable { + case act + case passThrough(HotkeyBailReason) +} + +enum HotkeyFire { + static func outcome( + frontmostBundleID: String?, + targetBundleIDs: Set, + clipboardHasImage: Bool + ) -> HotkeyFireOutcome { + guard let frontmostBundleID, targetBundleIDs.contains(frontmostBundleID) else { + return .passThrough(.frontmostNotATarget) + } + guard clipboardHasImage else { return .passThrough(.clipboardImageGone) } + return .act + } +} + +// MARK: - Manager + +/// A chord CopyCat wants but could not claim. Carries the binding as well as +/// the status so the menu can name the chord that is unavailable rather than +/// just reporting that something is wrong. +struct HotkeyFailure: Equatable, Sendable { + let action: HotkeyAction + let binding: HotkeyBinding + let status: OSStatus +} + +/// Owns the gap between a desired plan and what is actually registered with +/// the system. +@MainActor +final class HotkeyManager { + var onFire: ((HotkeyAction) -> Void)? + + /// A failed registration for a chord that is *still* wanted. A failed chord + /// is silent — nothing swallows the keystroke and nothing acts on it — so + /// this is the only signal that CopyCat is inert, which is also why it must + /// not outlive the plan that produced it. Ordered by action so the menu + /// doesn't flip between two broken chords from one reconcile to the next. + var lastFailure: HotkeyFailure? { + failures.sorted { $0.key.rawValue < $1.key.rawValue }.first?.value + } + + private let registrar: CarbonHotKeyRegistering + private var registered: [HotkeyAction: HotkeyBinding] = [:] + private var failures: [HotkeyAction: HotkeyFailure] = [:] + /// Failures already reported, so a chord another app permanently owns is + /// logged as a state rather than once per reconcile. + private var loggedFailures: [HotkeyAction: OSStatus] = [:] + private var loggedReleaseFailures: [HotkeyAction: OSStatus] = [:] + + init(registrar: CarbonHotKeyRegistering) { + self.registrar = registrar + registrar.onFire = { [weak self] action in + self?.onFire?(action) + } + } + + convenience init() { + self.init(registrar: SystemCarbonHotKeyRegistrar()) + } + + var activeBindings: [HotkeyAction: HotkeyBinding] { registered } + + func apply(_ desired: [HotkeyAction: HotkeyBinding], reason: String) { + // Prune before the early return, not after: a chord that failed to + // register and was then disabled leaves `desired == registered` true, + // so anything kept here would report CopyCat as inert forever. + failures = failures.filter { desired[$0.key] != nil } + loggedFailures = loggedFailures.filter { desired[$0.key] != nil } + + guard desired != registered else { return } + let before = registered + + // Every drop must complete before any claim: when a chord moves between + // actions (enabling broadcast on ⌘V while local paste holds it), a claim + // issued first hits the still-live registration and fails. + for (action, binding) in registered where desired[action] != binding { + let status = registrar.unregister(action) + guard status == noErr else { + // Keep it recorded. The system still holds the chord, and + // reporting it as released would claim ⌘V is free while it is + // in fact dead everywhere. The next apply retries the drop. + if loggedReleaseFailures[action] != status { + loggedReleaseFailures[action] = status + Log.hotkey.error("unregister \(binding.displayString) failed (OSStatus \(status)) — chord still claimed, will retry") + } + continue + } + loggedReleaseFailures[action] = nil + registered[action] = nil + } + + for (action, binding) in desired where registered[action] == nil { + let status = registrar.register( + action, + keyCode: UInt32(binding.keyCode), + carbonModifiers: binding.carbonModifiers) + guard status == noErr else { + failures[action] = HotkeyFailure(action: action, binding: binding, status: status) + if loggedFailures[action] != status { + loggedFailures[action] = status + Log.hotkey.error("register \(binding.displayString) failed (OSStatus \(status)) — chord will not be intercepted") + } + continue + } + registered[action] = binding + failures[action] = nil + loggedFailures[action] = nil + } + + // A failed claim leaves the held set unchanged and retries next time; + // don't narrate the retries. + guard registered != before else { return } + let held = registered.values.map(\.displayString).sorted().joined(separator: " ") + Log.hotkey.info("\(reason): holding [\(held.isEmpty ? "nothing" : held)]") + } + + /// Drops a single chord outside the plan cycle. Handing a swallowed + /// keystroke back needs the chord gone *first*, or the re-posted event + /// matches the still-live registration and comes straight back to us. + /// Reports whether the chord is now free; the next apply re-arms it if the + /// gate has reopened. + func release(_ action: HotkeyAction, reason: String) -> Bool { + guard let binding = registered[action] else { return true } + let status = registrar.unregister(action) + guard status == noErr else { + Log.hotkey.error("release \(binding.displayString) failed (OSStatus \(status)) — chord stays claimed") + return false + } + registered[action] = nil + Log.hotkey.info("\(reason): released \(binding.displayString)") + return true + } + + func releaseAll() { + registrar.unregisterAll() + registered = [:] + failures = [:] + loggedFailures = [:] + loggedReleaseFailures = [:] + } + + /// Teardown: drop the chords and the event handler behind them. + func invalidate() { + releaseAll() + registrar.invalidate() + } +} diff --git a/Sources/CopyCat/CopyCatApp.swift b/Sources/CopyCat/CopyCatApp.swift index 34feb50..c5f7a58 100644 --- a/Sources/CopyCat/CopyCatApp.swift +++ b/Sources/CopyCat/CopyCatApp.swift @@ -47,7 +47,7 @@ private enum MenuBarIconFactory { }() // Same paw with an exclamation badge in the corner: the persistent, - // glanceable "paste is broken" signal while Secure Input blocks the tap. + // glanceable "paste is broken" signal while Secure Input is active. static let blocked: NSImage = { let base = normal let size = base.size == .zero ? NSSize(width: 18, height: 18) : base.size @@ -81,6 +81,15 @@ private struct MenuBarLabel: View { } } +// The Accessibility grant gates every paste, so two menu surfaces offer it: +// the header when it's missing, and Options as a standing escape hatch. +private enum PrivacySettings { + static func openAccessibility() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { return } + NSWorkspace.shared.open(url) + } +} + private struct CopyCatMenu: View { @ObservedObject private var store = SettingsStore.shared @@ -114,9 +123,7 @@ private struct CopyCatMenu: View { } Divider() Button("Open Accessibility settings") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { - NSWorkspace.shared.open(url) - } + PrivacySettings.openAccessibility() } // The paste-attempt sensor (toast at the exact moment ⌘V is // pressed while blocked) needs Input Monitoring; hide the item @@ -215,13 +222,20 @@ private struct StatusHeader: View { @ObservedObject private var status = StatusModel.shared var body: some View { - let tapText = status.tapEnabled ? "Tap on" : "Tap off" - - Text("\(status.appName) — \(tapText)") + Text("\(status.appName) — \(status.hotkey.menuLabel)") .font(.headline) - // Secure Input silently blocks the tap for the whole session, so a - // green "Tap on" alone would be misleading — call out the culprit. + // Without Accessibility no chord is armed at all, so the header is the + // only place the user learns why ⌘V behaves normally again. + if status.hotkey == .needsAccessibility { + Button("Open Accessibility settings") { + PrivacySettings.openAccessibility() + } + } + + // Secure Input silently stops hot keys from firing for the whole + // session, so a green "Hotkey on" alone would be misleading — call out + // the culprit. // Alert-worthy blocks get the orange treatment plus a one-click fix; // benign holds (focused password prompt) get a quiet gray note. if let secureInput = status.secureInput { @@ -366,8 +380,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pasteHandler = PasteHandler() pasteHandler?.start() - SecureInputWatcher.shared.tapEnabledProvider = { [weak self] in - self?.pasteHandler?.isTapEnabled ?? false + SecureInputWatcher.shared.hotkeyStatusProvider = { [weak self] in + self?.pasteHandler?.hotkeyStatus ?? .off } SecureInputWatcher.shared.start() } diff --git a/Sources/CopyCat/HotkeyBinding.swift b/Sources/CopyCat/HotkeyBinding.swift index 9de9fef..ad8adac 100644 --- a/Sources/CopyCat/HotkeyBinding.swift +++ b/Sources/CopyCat/HotkeyBinding.swift @@ -1,3 +1,4 @@ +import Carbon.HIToolbox import CoreGraphics import Foundation @@ -11,7 +12,7 @@ private let modifierMask: UInt64 = CGEventFlags.maskControl.rawValue | CGEventFlags.maskShift.rawValue -struct HotkeyBinding: Equatable, Sendable { +struct HotkeyBinding: Equatable, Hashable, Sendable { var keyCode: Int var modifiers: UInt64 @@ -20,6 +21,29 @@ struct HotkeyBinding: Equatable, Sendable { self.modifiers = modifiers & modifierMask } + /// Rebuilds a binding from Carbon's modifier encoding. Inverse of + /// `carbonModifiers`. + init(keyCode: Int, carbonModifiers: UInt32) { + var flags: UInt64 = 0 + if carbonModifiers & UInt32(cmdKey) != 0 { flags |= CGEventFlags.maskCommand.rawValue } + if carbonModifiers & UInt32(optionKey) != 0 { flags |= CGEventFlags.maskAlternate.rawValue } + if carbonModifiers & UInt32(controlKey) != 0 { flags |= CGEventFlags.maskControl.rawValue } + if carbonModifiers & UInt32(shiftKey) != 0 { flags |= CGEventFlags.maskShift.rawValue } + self.init(keyCode: keyCode, modifiers: flags) + } + + /// This chord in the encoding `RegisterEventHotKey` expects. Carbon's + /// modifier bits and CGEventFlags share no values, so the two are never + /// interchangeable even though both describe the same four modifiers. + var carbonModifiers: UInt32 { + var carbon: UInt32 = 0 + if modifiers & CGEventFlags.maskCommand.rawValue != 0 { carbon |= UInt32(cmdKey) } + if modifiers & CGEventFlags.maskAlternate.rawValue != 0 { carbon |= UInt32(optionKey) } + if modifiers & CGEventFlags.maskControl.rawValue != 0 { carbon |= UInt32(controlKey) } + if modifiers & CGEventFlags.maskShift.rawValue != 0 { carbon |= UInt32(shiftKey) } + return carbon + } + // Virtual keycode 9 = "v" on the US layout — keycodes are positional, // so this fires regardless of which character "v" prints. // Local paste is intentionally not user-configurable: the whole point of @@ -29,11 +53,6 @@ struct HotkeyBinding: Equatable, Sendable { modifiers: CGEventFlags.maskCommand.rawValue ) - func matches(keyCode: Int64, flags: CGEventFlags) -> Bool { - guard Int(keyCode) == self.keyCode else { return false } - return matchesModifiers(flags) - } - /// Modifier-only match, for callers that already know the key (the IOHID /// sensor reports V by HID usage, not by CGEvent keycode). func matchesModifiers(_ flags: CGEventFlags) -> Bool { @@ -53,7 +72,7 @@ struct HotkeyBinding: Equatable, Sendable { } // Fixed set of broadcast chords. ⌘V coincides with local paste — broadcast -// already wins precedence in PasteHandler, so picking ⌘V means broadcast +// wins that collision in HotkeyPlan, so picking ⌘V means broadcast // effectively replaces local while broadcast is enabled. enum BroadcastHotkey: String, CaseIterable, Codable, Identifiable, Sendable { case cmdV diff --git a/Sources/CopyCat/Logger.swift b/Sources/CopyCat/Logger.swift index 1dd988f..c0558ae 100644 --- a/Sources/CopyCat/Logger.swift +++ b/Sources/CopyCat/Logger.swift @@ -81,10 +81,9 @@ enum LogSubsystem { enum Log { static let app = AppLogger(category: "App") - static let tap = AppLogger(category: "Tap") + static let hotkey = AppLogger(category: "Hotkey") static let cmdV = AppLogger(category: "Local") static let cmdOptV = AppLogger(category: "Broadcast") - static let watchdog = AppLogger(category: "Watchdog") static let secure = AppLogger(category: "SecureInput") } diff --git a/Sources/CopyCat/PasteAttemptSensor.swift b/Sources/CopyCat/PasteAttemptSensor.swift index 723f263..c5e44f5 100644 --- a/Sources/CopyCat/PasteAttemptSensor.swift +++ b/Sources/CopyCat/PasteAttemptSensor.swift @@ -1,10 +1,10 @@ import AppKit import IOKit.hid -// Detects ⌘V while Secure Input is blocking the event tap. Secure Input hides -// keyboard events from CGEventTaps but not from IOHID device monitoring, so -// this is the only way to know the user just tried to paste while blocked — -// the tap literally never sees the keystroke. Listen-only (no seize): the +// Detects ⌘V while Secure Input is blocking CopyCat. Secure Input hides +// keyboard events from event taps and hot key dispatch, but not from IOHID +// device monitoring, so this is the only way to know the user just tried to +// paste while blocked. Listen-only (no seize): the // original event still reaches the frontmost app untouched. Non-chord keys // are discarded in the callback; nothing is stored or logged. // diff --git a/Sources/CopyCat/PasteCooldown.swift b/Sources/CopyCat/PasteCooldown.swift new file mode 100644 index 0000000..d4815c9 --- /dev/null +++ b/Sources/CopyCat/PasteCooldown.swift @@ -0,0 +1,36 @@ +import Foundation + +/// One physical keystroke must produce at most one typed path. +/// +/// Two independent paths can observe the same ⌘V: the registered chord, and +/// the IOHID paste-attempt sensor that runs while Secure Input blocks. Whether +/// a registered hot key still fires under Secure Input is not documented, so +/// this guard is shared by both paths rather than owned by either — it has to +/// hold whichever way that behaves. +/// +/// Scoped per action, because only same-action deliveries can be the same +/// keystroke. A local paste followed by a broadcast is two deliberate presses +/// and both must land. +@MainActor +final class PasteCooldown { + static let shared = PasteCooldown() + + private let window: TimeInterval + private var lastAt: [HotkeyAction: TimeInterval] = [:] + + /// Wide enough to cover both paths reacting to one keystroke. A physical + /// double-tap of the same chord inside the window is collapsed — the + /// accepted cost of never typing the same path twice from a single press. + init(window: TimeInterval = 1) { + self.window = window + } + + /// Records this attempt and reports whether it may proceed. A rejected + /// attempt does not extend the window, so a held-down key can't starve the + /// next deliberate paste indefinitely. + func claim(_ action: HotkeyAction, now: TimeInterval = Date().timeIntervalSinceReferenceDate) -> Bool { + if let last = lastAt[action], now - last <= window { return false } + lastAt[action] = now + return true + } +} diff --git a/Sources/CopyCat/PasteHandler.swift b/Sources/CopyCat/PasteHandler.swift index b396a28..718e297 100644 --- a/Sources/CopyCat/PasteHandler.swift +++ b/Sources/CopyCat/PasteHandler.swift @@ -1,268 +1,314 @@ import AppKit import CoreGraphics -// All mutation happens on the main thread (eventtap callback + watchdog -// timer fire there). We mark Sendable for the [weak self] capture in -// the watchdog closure; concurrent access isn't actually possible. -final class PasteHandler: @unchecked Sendable { - private var tap: CFMachPort? - private var runloopSource: CFRunLoopSource? - private var watchdog: Timer? - private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - private var starvedRebuilds = 0 - - func start() { - installTap() - startWatchdog() +// Registers CopyCat's chords with the system instead of tapping the keystroke +// stream, and arms them only in the narrow window where CopyCat would actually +// act: a target terminal frontmost, an image on the clipboard, Accessibility +// granted. +// +// The arming is not an optimization, it's the whole design. A Carbon hot key is +// all-or-nothing — once registered the OS matches and swallows the chord before +// any app sees it, with no way to decline and let the keystroke through — and +// the chord in question is plain ⌘V. Holding it unconditionally would break +// paste in every app on the system. Registering only across the window where +// the keystroke was ours anyway reproduces the old conditional behavior. +// +// The gate inputs are all observable outside the keystroke path (workspace +// activation notifications, pasteboard change count, TCC trust), so nothing +// here sits between the user and their input. A handler that hangs can only +// delay CopyCat's own paste. +// +// The gate is sampled up to one poll interval before the keystroke lands, so +// it can be stale by the time a chord fires. That case is not a no-op: the +// keystroke is already gone, and `passThrough` has to hand it back. + +/// The system inputs the arming decision reads. Injectable so the gate — the +/// rule standing between "⌘V works everywhere" and "⌘V is dead system-wide" — +/// can be exercised without a frontmost app, a real clipboard, a granted +/// Accessibility permission, or posting events at the live session. +@MainActor +struct HotkeyEnvironment { + var frontmostBundleID: @MainActor () -> String? + var clipboardHasImage: @MainActor () -> Bool + var clipboardChangeCount: @MainActor () -> Int + var accessibilityTrusted: @MainActor () -> Bool + var postChord: @MainActor (HotkeyBinding) -> Void + /// Shared with the Secure Input sensor in production, per-instance in + /// tests: a process-global wall-clock window would make anything that + /// reaches the paste itself depend on how fast the suite runs. + var pasteCooldown: PasteCooldown + var performPaste: @MainActor (HotkeyAction) -> Void + + static let system = HotkeyEnvironment( + frontmostBundleID: { NSWorkspace.shared.frontmostApplication?.bundleIdentifier }, + clipboardHasImage: { NSPasteboard.general.hasImageType }, + clipboardChangeCount: { NSPasteboard.general.changeCount }, + accessibilityTrusted: { AXIsProcessTrusted() }, + postChord: { Typer.postChord($0) }, + pasteCooldown: .shared, + performPaste: { action in + // The clipboard is re-read on the worker, which bails cleanly if + // the image vanished; keeping the read off the hot key handler + // keeps it short. + switch action { + case .localPaste: + DispatchQueue.global(qos: .userInitiated).async { + ImagePaste.handleLocal() + } + case .broadcast: + DispatchQueue.global(qos: .userInitiated).async { + Broadcast.handle() + } + } + }) +} - NSWorkspace.shared.notificationCenter.addObserver( - forName: NSWorkspace.didWakeNotification, object: nil, queue: .main - ) { [weak self] _ in - Log.tap.info("wake detected — reinstalling tap") - self?.teardownTap() - self?.installTap() - } +@MainActor +final class PasteHandler { + private let hotkeys: HotkeyManager + private let environment: HotkeyEnvironment + private var workspaceObservers: [NSObjectProtocol] = [] + private var defaultsObservers: [NSObjectProtocol] = [] + private var clipboardPoll: Timer? + private var lastClipboardChangeCount = 0 + + // The pasteboard posts no change notification, so this poll bounds how + // stale the arming decision can be — "screenshot, then immediately ⌘V" + // has to land. A tick costs one integer read unless the clipboard moved, + // and the timer only runs while a target app is frontmost. + private static let clipboardPollInterval: TimeInterval = 0.25 + + init(hotkeys: HotkeyManager = HotkeyManager(), environment: HotkeyEnvironment = .system) { + self.hotkeys = hotkeys + self.environment = environment } - func stop() { - NSWorkspace.shared.notificationCenter.removeObserver(self) - watchdog?.invalidate() - watchdog = nil - teardownTap() + isolated deinit { + // The run loop retains the poll timer, so without this it keeps ticking + // against a dead weak self for the life of the process. + removeObservers() + setClipboardPolling(false) } - // Public probe so the menu bar status header can reflect tap state. - var isTapEnabled: Bool { - guard let tap else { return false } - return CGEvent.tapIsEnabled(tap: tap) + func start() { + hotkeys.onFire = { [weak self] action in + self?.fire(action) + } + promptForAccessibilityIfNeeded() + installObservers() + reconcile(reason: "startup") } - // Push tap state into the observable menu model. The menu can't read it - // live (the read isn't observable, so SwiftUI froze it at launch — the old - // "Tap off" bug), so we publish on every state change and once per - // watchdog tick; SecureInputWatcher also refreshes it on its own poll. - // Callers are always on the main thread (start / wake observer / - // main-runloop timer), so assumeIsolated is safe and avoids an async hop. - // Secure Input state is owned end-to-end by SecureInputWatcher. - private func publishStatus() { - let enabled = isTapEnabled - MainActor.assumeIsolated { - let model = StatusModel.shared - if model.tapEnabled != enabled { model.tapEnabled = enabled } - } + func stop() { + removeObservers() + setClipboardPolling(false) + hotkeys.onFire = nil + hotkeys.invalidate() } - private func teardownTap() { - if let tap { - CGEvent.tapEnable(tap: tap, enable: false) - if let src = runloopSource { - CFRunLoopRemoveSource(CFRunLoopGetMain(), src, .commonModes) - } - CFMachPortInvalidate(tap) + /// What the menu header should say about interception. Not "armed right + /// now", which flips with every app switch and would read as breakage. + /// Every not-working answer names its own cause: the alternative is one + /// "off" that could mean a toggle, a missing grant, or a chord another app + /// permanently owns, none of which are fixed the same way. + var hotkeyStatus: HotkeyStatus { + guard Settings.enableLocalPaste || Settings.enableBroadcast else { return .off } + guard environment.accessibilityTrusted() else { return .needsAccessibility } + if let failure = hotkeys.lastFailure { + return .unavailable(chord: failure.binding.displayString) } - tap = nil - runloopSource = nil - publishStatus() + return .on } - private func installTap() { - // Publish on every exit path so a failed install (no Accessibility, - // nil tap) shows "Tap off" rather than a stale value. - defer { publishStatus() } - guard ensureAccessibility(prompt: true) else { - Log.tap.error("Accessibility not granted — tap NOT installed. Grant in System Settings → Privacy & Security → Accessibility, then relaunch.") - return - } + // MARK: - Arming - // Subscribe to keyDown plus the two "OS killed your tap" events so we - // can re-enable inline without losing keystrokes. - let mask: CGEventMask = - (1 << CGEventType.keyDown.rawValue) | - (1 << CGEventType.tapDisabledByTimeout.rawValue) | - (1 << CGEventType.tapDisabledByUserInput.rawValue) - - let userInfo = Unmanaged.passUnretained(self).toOpaque() - - let tap = CGEvent.tapCreate( - tap: .cghidEventTap, - place: .headInsertEventTap, - options: .defaultTap, - eventsOfInterest: mask, - callback: { _, type, event, refcon in - guard let refcon else { return Unmanaged.passUnretained(event) } - let handler = Unmanaged.fromOpaque(refcon).takeUnretainedValue() - return handler.handle(type: type, event: event) - }, - userInfo: userInfo - ) - - guard let tap else { - Log.tap.error("CGEvent.tapCreate returned nil — Accessibility may have been revoked") - return + private func installObservers() { + let wnc = NSWorkspace.shared.notificationCenter + func workspace(_ name: Notification.Name, _ reason: String) { + let token = wnc.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.reconcile(reason: reason) + } + } + workspaceObservers.append(token) } - let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) - CFRunLoopAddSource(CFRunLoopGetMain(), src, .commonModes) - CGEvent.tapEnable(tap: tap, enable: true) + workspace(NSWorkspace.didActivateApplicationNotification, "app activated") + // Lock and sleep post no activation notification, so a chord armed in a + // terminal is still held when the machine comes back — possibly with a + // different app frontmost and a different clipboard. Re-derive it from + // what is true now. + workspace(NSWorkspace.didWakeNotification, "wake") + workspace(NSWorkspace.screensDidWakeNotification, "screens woke") + + // Every user-facing toggle lands in UserDefaults, so one observer covers + // enabling/disabling a chord, changing the broadcast chord, and editing + // the target app list. + let defaults = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.reconcile(reason: "settings changed") + } + } + defaultsObservers.append(defaults) + } - self.tap = tap - self.runloopSource = src - Log.tap.info("installed (enabled=\(CGEvent.tapIsEnabled(tap: tap)))") + private func removeObservers() { + workspaceObservers.forEach { NSWorkspace.shared.notificationCenter.removeObserver($0) } + workspaceObservers = [] + defaultsObservers.forEach { NotificationCenter.default.removeObserver($0) } + defaultsObservers = [] } - private func frontmostBundleID() -> String? { - NSWorkspace.shared.frontmostApplication?.bundleIdentifier + func reconcile(reason: String) { + let frontmost = environment.frontmostBundleID() + let targets = Settings.targetBundleIDs + let targetFrontmost = frontmost.map(targets.contains) ?? false + + setClipboardPolling(targetFrontmost) + lastClipboardChangeCount = environment.clipboardChangeCount() + + // Only ask the pasteboard when the answer could change the plan; this + // runs on every app switch and every clipboard change. + let clipboardHasImage = targetFrontmost && environment.clipboardHasImage() + + // Re-read on every reconcile rather than caching from startup: the + // grant can land while CopyCat is running, and this is what makes it + // take effect without a restart. + let accessibilityTrusted = environment.accessibilityTrusted() + + hotkeys.apply( + HotkeyPlan.desired( + frontmostBundleID: frontmost, + targetBundleIDs: targets, + clipboardHasImage: clipboardHasImage, + accessibilityTrusted: accessibilityTrusted, + localPasteEnabled: Settings.enableLocalPaste, + broadcastEnabled: Settings.enableBroadcast, + broadcastBinding: Settings.broadcastHotkey.binding), + reason: reason) + + publishStatus() } - private func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { - lastEventTime = CFAbsoluteTimeGetCurrent() + var isPollingClipboard: Bool { clipboardPoll != nil } - // OS killed the tap — re-enable in place. The event itself is - // synthetic and gets discarded. - if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { - let reason = type == .tapDisabledByTimeout ? "timeout" : "userInput" - Log.tap.info("OS disabled tap (\(reason)) — re-enabling") - if let tap { CGEvent.tapEnable(tap: tap, enable: true) } - return nil + private func setClipboardPolling(_ enabled: Bool) { + guard enabled != (clipboardPoll != nil) else { return } + guard enabled else { + clipboardPoll?.invalidate() + clipboardPoll = nil + return } - - guard type == .keyDown else { - return Unmanaged.passUnretained(event) + clipboardPoll = Timer.scheduledTimer( + withTimeInterval: Self.clipboardPollInterval, repeats: true + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.pollClipboard() + } } + } - let keyCode = event.getIntegerValueField(.keyboardEventKeycode) - let flags = event.flags - - let isLocal = Settings.enableLocalPaste && HotkeyBinding.localPaste.matches(keyCode: keyCode, flags: flags) - let isBroadcast = Settings.enableBroadcast && Settings.broadcastHotkey.binding.matches(keyCode: keyCode, flags: flags) + private func pollClipboard() { + let count = environment.clipboardChangeCount() + guard count != lastClipboardChangeCount else { return } + reconcile(reason: "clipboard changed") + } - guard isLocal || isBroadcast else { - return Unmanaged.passUnretained(event) - } + // Push readiness into the observable menu model. The menu can't read it + // live (the read isn't observable, so SwiftUI froze it at launch — the old + // permanently-wrong header bug), so publish on every state change; + // SecureInputWatcher also refreshes it on its own poll. Secure Input state + // is owned end-to-end by SecureInputWatcher. + private func publishStatus() { + let status = hotkeyStatus + let model = StatusModel.shared + if model.hotkey != status { model.hotkey = status } + } - // Broadcast wins ties: if both bindings collide, the more-specific - // (more-modifiers) chord is the broadcast one in the default config. - let category = isBroadcast ? Log.cmdOptV : Log.cmdV + // MARK: - Dispatch - let frontmost = frontmostBundleID() - guard let frontmost, Settings.targetBundleIDs.contains(frontmost) else { - category.info("bail — frontmost is \(frontmost ?? "nil")") - return Unmanaged.passUnretained(event) - } + func fire(_ action: HotkeyAction) { + let category = action == .broadcast ? Log.cmdOptV : Log.cmdV - // Quick clipboard probe: just check declared types, don't read the - // image. The full read happens off-thread so this callback returns - // in <5ms even on a giant Retina capture. - guard NSPasteboard.general.hasImageType else { - category.info("bail — clipboard has no image") - return Unmanaged.passUnretained(event) - } + // Both gate inputs are re-read rather than trusted from arming time: + // an activation notification or a clipboard change can land after a + // keystroke the user already pressed, and typing a file path into the + // wrong app is worse than not pasting. + let outcome = HotkeyFire.outcome( + frontmostBundleID: environment.frontmostBundleID(), + targetBundleIDs: Settings.targetBundleIDs, + clipboardHasImage: environment.clipboardHasImage()) - if isBroadcast { - DispatchQueue.global(qos: .userInitiated).async { - Broadcast.handle() - } - } else { - DispatchQueue.global(qos: .userInitiated).async { - ImagePaste.handleLocal() - } + if case .passThrough(let reason) = outcome { + passThrough(action, reason: reason, category: category) + return } - return nil - } - private func startWatchdog() { - watchdog = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in - self?.checkAndRevive() + guard environment.pasteCooldown.claim(action) else { + category.info("ignored — this chord already pasted moments ago, so this is one keystroke arriving twice") + return } - } - // How long the tap must be silent before we look for blocked delivery paths. - // Silence is NOT proof of a dead tap — it's identical to the user simply not - // typing — so it only gates checks that have an independent signal. Secure - // Input is detected directly, and real tap death must surface via tapIsEnabled, - // the OS tapDisabled events, or the starved-queue check below. - private static let staleTapInterval: CFTimeInterval = 90 - - // WindowServer-reported queue latency above which an "enabled" tap is - // treated as dead. Healthy FILTER taps report µs–ms; WindowServer's own - // per-event tap timeout is single-digit seconds, so anything past 5s means - // events are rotting in the queue, not being processed slowly. - private static let starvedTapLatencyUs: Float = 5_000_000 - - // How WindowServer sees our tap. A starved tap — mach port still registered - // but its events no longer being serviced — keeps reporting enabled=true, - // so tapIsEnabled can't detect it. The queue latency WindowServer tracks - // per tap can: it grows in lockstep with wall clock while an event sits - // undelivered. - private func reportedTapLatencyUs() -> Float? { - var count: UInt32 = 0 - guard CGGetEventTapList(0, nil, &count) == .success, count > 0 else { return nil } - var taps = [CGEventTapInformation](repeating: CGEventTapInformation(), count: Int(count)) - guard CGGetEventTapList(count, &taps, &count) == .success else { return nil } - let pid = getpid() - return taps.prefix(Int(count)) - .filter { $0.tappingProcess == pid } - .map(\.avgUsecLatency) - .max() + environment.performPaste(action) } - private func checkAndRevive() { - // Refresh the menu model once per tick regardless of which branch we - // take — this is what keeps Secure Input status current in the menu. - defer { publishStatus() } - guard let tap else { - Log.watchdog.info("tap is nil; reinstalling") - installTap() - return - } - let enabled = CGEvent.tapIsEnabled(tap: tap) - let silent = CFAbsoluteTimeGetCurrent() - lastEventTime - - // Starved tap: enabled by every local measure, but WindowServer shows - // events queued and unserviced. Re-enabling is a no-op for this state; - // only a full rebuild recovers. Gate on silence too so one slow event - // around a sleep/wake transition doesn't churn a healthy tap. Repeated - // rebuilds point to an upstream event-delivery/session problem; the count - // in the log line is the diagnostic signal. - if enabled && silent > Self.staleTapInterval, - let latencyUs = reportedTapLatencyUs(), latencyUs > Self.starvedTapLatencyUs { - starvedRebuilds += 1 - Log.watchdog.error("tap starved — enabled but WindowServer queue latency \(Int(latencyUs / 1_000_000))s; rebuilding (rebuild #\(starvedRebuilds) since last healthy tick)") - teardownTap() - installTap() - return + /// Hands back a keystroke CopyCat swallowed but won't act on. Without this + /// the user's ⌘V is destroyed: the OS consumed it on our behalf and the + /// focused app never saw it. + private func passThrough(_ action: HotkeyAction, reason: HotkeyBailReason, category: AppLogger) { + let binding = hotkeys.activeBindings[action] + + // Release before posting, always. The synthetic event carries the same + // modifiers the hot key matches on, so posting it while the chord is + // still claimed feeds it straight back into this handler — this + // ordering is the entire loop guard. + let released = binding != nil + && hotkeys.release(action, reason: "passthrough (\(reason.rawValue))") + + // The gate that just failed is shared by every chord, not only the one + // that fired. A sibling left armed goes on swallowing keystrokes for a + // window that has already closed, so drop them in the same pass rather + // than waiting for the next reconcile — including when the fired chord + // itself turned out not to be held. + for sibling in hotkeys.activeBindings.keys where sibling != action { + guard let siblingBinding = hotkeys.activeBindings[sibling] else { continue } + if hotkeys.release(sibling, reason: "passthrough sibling (\(reason.rawValue))") { + category.info("also released \(siblingBinding.displayString) — the gate is closed for every chord") + } else { + category.error("could not release \(siblingBinding.displayString) on passthrough — it stays claimed") + } } - // Long silence with the tap still "enabled" has one confirmed cause: - // Secure Input swallowing key events. A reinstall can't defeat Secure - // Input, and rebuilding on silence just churned a healthy, merely-idle - // tap every 30s — so skip the rebuild path entirely. Alerting the user - // is SecureInputWatcher's job; this branch only protects the tap. - if enabled && silent > Self.staleTapInterval, case .blocked(let owner) = SecureInput.status() { - Log.watchdog.info("tap silent \(Int(silent))s with Secure Input active (\(owner?.description ?? "unknown source")) — not rebuilding") + guard let binding else { + category.info("bail (\(reason.rawValue)) — no chord held for this action, nothing to hand back") return } - if enabled { - starvedRebuilds = 0 - Log.watchdog.info("tap.enabled=true") + guard released, hotkeys.activeBindings[action] == nil else { + category.error("passthrough aborted — \(binding.displayString) is still registered; posting it would re-enter this handler") return } - Log.watchdog.info("tap.enabled=false; re-enabling") - CGEvent.tapEnable(tap: tap, enable: true) - if !CGEvent.tapIsEnabled(tap: tap) { - Log.watchdog.error("re-enable did not stick; full reinstall") - teardownTap() - installTap() - } + + category.info("bail (\(reason.rawValue)) — released \(binding.displayString) and handed the keystroke back") + environment.postChord(binding) + // Re-arming is left to the normal reconcile path. The gate just failed, + // so it stays down until the conditions actually return. } - private func ensureAccessibility(prompt: Bool) -> Bool { + // The hot key itself needs no TCC grant, but the paste does: typing the + // file path posts synthetic key events, which the OS silently drops for an + // untrusted process. Prompting at startup turns that into a permission + // dialog rather than a menu that says "needs Accessibility" with no + // explanation of when it was asked for. + private func promptForAccessibilityIfNeeded() { // Hardcoded value of kAXTrustedCheckOptionPrompt — referencing the // global var trips Swift 6 strict concurrency (it's non-Sendable). let key = "AXTrustedCheckOptionPrompt" as CFString - let opts = [key: prompt] as CFDictionary - return AXIsProcessTrustedWithOptions(opts) + let opts = [key: true] as CFDictionary + guard !AXIsProcessTrustedWithOptions(opts) else { return } + Log.hotkey.error("Accessibility not granted — chords stay unregistered until it is. Grant in System Settings → Privacy & Security → Accessibility.") } } @@ -311,4 +357,24 @@ enum Typer { up?.post(tap: .cghidEventTap) } } + + /// Re-posts a chord CopyCat swallowed and decided not to act on, so the + /// focused app receives the keystroke the user actually pressed. + /// + /// Unlike `type`, this deliberately carries modifier flags — it is the + /// chord — so it *will* match a live registration of the same chord. Only + /// call it once that registration is gone. + static func postChord(_ binding: HotkeyBinding) { + guard let source = CGEventSource(stateID: .hidSystemState) else { return } + let flags = CGEventFlags(rawValue: binding.modifiers) + let key = CGKeyCode(binding.keyCode) + + let down = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: true) + down?.flags = flags + down?.post(tap: .cghidEventTap) + + let up = CGEvent(keyboardEventSource: source, virtualKey: key, keyDown: false) + up?.flags = flags + up?.post(tap: .cghidEventTap) + } } diff --git a/Sources/CopyCat/SecureInput.swift b/Sources/CopyCat/SecureInput.swift index c2ed7a4..37f21f0 100644 --- a/Sources/CopyCat/SecureInput.swift +++ b/Sources/CopyCat/SecureInput.swift @@ -4,11 +4,11 @@ import Foundation import IOKit // macOS Secure Input (EnableSecureEventInput) is session-wide, not per-app: -// while any one process holds it, the kernel stops delivering key events to -// *every* CGEventTap, regardless of which app is frontmost. At the tap this is -// indistinguishable from "enabled but dead" — same symptom, opposite cause. -// A tap reinstall cannot bypass Secure Input, so the watchdog must detect it -// explicitly instead of churning the tap. +// while any one process holds it, the kernel routes key events straight to the +// secure field, so event taps see nothing and registered hot keys never fire — +// regardless of which app is frontmost. From the receiving side that is +// indistinguishable from the user simply not pressing the chord, so the state +// has to be detected explicitly rather than inferred from silence. enum SecureInput { /// Who holds the lock, and whether that process still exists. struct Owner: Equatable, Sendable { @@ -74,7 +74,7 @@ enum SecureInput { var buffer = [CChar](repeating: 0, count: 128) let length = proc_name(pid, &buffer, UInt32(buffer.count)) guard length > 0 else { return nil } - return String(cString: buffer) + return String(decoding: buffer.prefix(Int(length)).map(UInt8.init(bitPattern:)), as: UTF8.self) } /// PID recorded as the Secure Input owner, or nil when the key is absent. diff --git a/Sources/CopyCat/SecureInputWatcher.swift b/Sources/CopyCat/SecureInputWatcher.swift index 3e6a67f..b262800 100644 --- a/Sources/CopyCat/SecureInputWatcher.swift +++ b/Sources/CopyCat/SecureInputWatcher.swift @@ -3,24 +3,24 @@ import CoreGraphics // Owns Secure Input detection and every user-facing surface for it: menu // model, menu-bar icon badge, HUD toast, and notification banner. PasteHandler -// keeps only tap health — it can't own alerting because a blocked tap receives -// nothing, so it can't even see the state change promptly. +// keeps only hot key health — it can't own alerting because a blocked hot key +// never fires, so it can't even see the state change promptly. // // Detection is layered: // - a 2s poll (1s while blocked, to announce recovery fast), // - probes at the moments a block is born or becomes relevant: screen // unlock (the stuck-loginwindow bug appears exactly there), wake, // screensaver stop, and app activation, -// - optionally the IOHID paste-attempt sensor while blocked (the event tap -// is blind then, but IOHID still sees ⌘V), so the toast can fire at the +// - optionally the IOHID paste-attempt sensor while blocked (hot keys don't +// fire then, but IOHID still sees ⌘V), so the toast can fire at the // exact moment the user tries to paste. @MainActor final class SecureInputWatcher { static let shared = SecureInputWatcher() /// Lets one poll publish both halves of the menu header; the watcher has - /// no other reason to know about the tap. - var tapEnabledProvider: (@MainActor () -> Bool)? + /// no other reason to know about the hot keys. + var hotkeyStatusProvider: (@MainActor () -> HotkeyStatus)? private var pollTimer: Timer? private var pollingWhileBlocked = false @@ -43,9 +43,6 @@ final class SecureInputWatcher { private var displayedKey: String? private var sensor: PasteAttemptSensor? - private var lastDegradedPasteAt: TimeInterval = 0 - /// Physical double-taps aside, one ⌘V should produce one typed path. - private static let degradedPasteCooldown: TimeInterval = 1 private static let idleInterval: TimeInterval = 2 private static let blockedInterval: TimeInterval = 1 @@ -227,10 +224,10 @@ final class SecureInputWatcher { } private func publishModel(_ presentation: SecureInputPresentation?) { - let tapEnabled = tapEnabledProvider?() + let hotkeyStatus = hotkeyStatusProvider?() let alerting = policy.isAlerting let model = StatusModel.shared - if let tapEnabled, model.tapEnabled != tapEnabled { model.tapEnabled = tapEnabled } + if let hotkeyStatus, model.hotkey != hotkeyStatus { model.hotkey = hotkeyStatus } if model.secureInput != presentation { model.secureInput = presentation } if model.secureInputAlerting != alerting { model.secureInputAlerting = alerting } } @@ -281,9 +278,10 @@ final class SecureInputWatcher { guard pasteboard.hasImageType else { return } if degradedPasteAllowed(presentation: presentation, flags: flags, pasteboard: pasteboard) { - let now = Date().timeIntervalSinceReferenceDate - guard now - lastDegradedPasteAt > Self.degradedPasteCooldown else { return } - lastDegradedPasteAt = now + // Shared with the hot key path: if a registered chord does still + // fire under Secure Input, one keystroke reaches both paths and + // would otherwise type the path twice. + guard PasteCooldown.shared.claim(.localPaste) else { return } Log.secure.info("degraded paste: \(HotkeyBinding.localPaste.displayString) seen via HID while blocked — typing image path despite Secure Input (experimental)") SecureInputHUD.shared.showDegradedAttempt(presentation) DispatchQueue.global(qos: .userInitiated).async { diff --git a/Sources/CopyCat/StatusModel.swift b/Sources/CopyCat/StatusModel.swift index 356940c..4555c44 100644 --- a/Sources/CopyCat/StatusModel.swift +++ b/Sources/CopyCat/StatusModel.swift @@ -1,17 +1,41 @@ import Foundation import SwiftUI -// Single source of truth for the menu header. The header used to read tap and -// Secure Input state directly off PasteHandler, but those reads aren't -// observable — SwiftUI evaluated them once (at launch, before the tap finished -// installing) and never refreshed, so the menu showed a permanent, wrong -// "Tap off". Publishing the state here makes the header re-render whenever it -// actually changes. PasteHandler is the only writer (on the main thread). +/// Header state for the hot keys. The three not-working states are kept apart +/// because their recoveries have nothing in common: `off` is a toggle the user +/// set, `needsAccessibility` is a grant only System Settings can give, and +/// `unavailable` means something else on the system already owns the chord — +/// which is otherwise indistinguishable from the feature simply being off. +enum HotkeyStatus: Equatable, Sendable { + case on + case off + case needsAccessibility + case unavailable(chord: String) + + var menuLabel: String { + switch self { + case .on: "Hotkey on" + case .off: "Hotkey off" + case .needsAccessibility: "Hotkey needs Accessibility" + case .unavailable(let chord): "\(chord) unavailable (another app may hold it)" + } + } +} + +// Single source of truth for the menu header. The header used to read hot key +// and Secure Input state directly off PasteHandler, but those reads aren't +// observable — SwiftUI evaluated them once at launch and never refreshed, so +// the menu showed a permanently wrong state. Publishing here makes the header +// re-render whenever it actually changes. PasteHandler is the only writer +// (on the main thread). @MainActor final class StatusModel: ObservableObject { static let shared = StatusModel() - @Published var tapEnabled = false + /// Why the chords are or aren't intercepting — not whether one is armed + /// this instant. Arming tracks the frontmost app, so surfacing it would + /// make the header flicker on every app switch. + @Published var hotkey: HotkeyStatus = .off /// Current Secure Input state for the menu; nil when clear. Includes /// benign holds (expected kind) so the menu can explain them quietly. @Published var secureInput: SecureInputPresentation? diff --git a/Tests/CopyCatTests/CarbonHotKeyTests.swift b/Tests/CopyCatTests/CarbonHotKeyTests.swift new file mode 100644 index 0000000..db3d124 --- /dev/null +++ b/Tests/CopyCatTests/CarbonHotKeyTests.swift @@ -0,0 +1,574 @@ +import Carbon.HIToolbox +import CoreGraphics +import XCTest +@testable import CopyCat + +// MARK: - Fake + +/// Records what the manager asked for instead of claiming real chords — +/// registering ⌘V for real would swallow it for the whole login session, +/// including whatever is running the tests. +@MainActor +final class FakeRegistrar: CarbonHotKeyRegistering { + enum Call: Equatable { + case register(HotkeyAction, keyCode: UInt32, carbonModifiers: UInt32) + case unregister(HotkeyAction) + case unregisterAll + case invalidate + } + + var onFire: ((HotkeyAction) -> Void)? + private(set) var calls: [Call] = [] + private(set) var live: Set = [] + + /// Status returned instead of success for these actions. + var failures: [HotkeyAction: OSStatus] = [:] + /// Same, for drops: the system keeps holding a chord it refused to release. + var unregisterFailures: [HotkeyAction: OSStatus] = [:] + + func register(_ action: HotkeyAction, keyCode: UInt32, carbonModifiers: UInt32) -> OSStatus { + calls.append(.register(action, keyCode: keyCode, carbonModifiers: carbonModifiers)) + if let status = failures[action] { return status } + live.insert(action) + return noErr + } + + func unregister(_ action: HotkeyAction) -> OSStatus { + calls.append(.unregister(action)) + if let status = unregisterFailures[action] { return status } + live.remove(action) + return noErr + } + + func unregisterAll() { + calls.append(.unregisterAll) + live.removeAll() + } + + func invalidate() { + calls.append(.invalidate) + live.removeAll() + } + + func reset() { calls = [] } + + var registerCount: Int { + calls.filter { if case .register = $0 { return true } else { return false } }.count + } + + /// Index of the last drop and the first claim, for asserting that the + /// manager never claims a chord before releasing whatever held it. + var lastUnregisterIndex: Int? { + calls.lastIndex { if case .unregister = $0 { return true } else { return false } } + } + + var firstRegisterIndex: Int? { + calls.firstIndex { if case .register = $0 { return true } else { return false } } + } +} + +// MARK: - Modifier conversion + +final class HotkeyBindingCarbonTests: XCTestCase { + private func binding(_ flags: CGEventFlags...) -> HotkeyBinding { + HotkeyBinding(keyCode: 9, modifiers: flags.reduce(UInt64(0)) { $0 | $1.rawValue }) + } + + func testEachModifierMapsToItsCarbonBit() { + XCTAssertEqual(binding(.maskCommand).carbonModifiers, UInt32(cmdKey)) + XCTAssertEqual(binding(.maskAlternate).carbonModifiers, UInt32(optionKey)) + XCTAssertEqual(binding(.maskControl).carbonModifiers, UInt32(controlKey)) + XCTAssertEqual(binding(.maskShift).carbonModifiers, UInt32(shiftKey)) + } + + func testNoModifiersProducesNoCarbonBits() { + XCTAssertEqual(HotkeyBinding(keyCode: 9, modifiers: 0).carbonModifiers, 0) + } + + func testModifiersCombine() { + XCTAssertEqual( + binding(.maskCommand, .maskAlternate).carbonModifiers, + UInt32(cmdKey) | UInt32(optionKey)) + XCTAssertEqual( + binding(.maskCommand, .maskControl, .maskShift).carbonModifiers, + UInt32(cmdKey) | UInt32(controlKey) | UInt32(shiftKey)) + } + + func testLocalPasteConvertsToCommandOnly() { + XCTAssertEqual(HotkeyBinding.localPaste.carbonModifiers, UInt32(cmdKey)) + XCTAssertEqual(HotkeyBinding.localPaste.keyCode, 9) + } + + // Every combination round-trips: the two encodings share no bit values, so + // a one-directional mapping error would otherwise only show up as a chord + // that silently registers under the wrong modifiers. + func testEveryModifierCombinationRoundTrips() { + let all: [CGEventFlags] = [.maskCommand, .maskAlternate, .maskControl, .maskShift] + for mask in 0..<(1 << all.count) { + var flags: UInt64 = 0 + for (index, flag) in all.enumerated() where mask & (1 << index) != 0 { + flags |= flag.rawValue + } + let original = HotkeyBinding(keyCode: 9, modifiers: flags) + let restored = HotkeyBinding(keyCode: 9, carbonModifiers: original.carbonModifiers) + XCTAssertEqual(restored, original, "round trip lost modifiers for mask \(mask)") + } + } + + func testEveryBroadcastChordRoundTrips() { + for chord in BroadcastHotkey.allCases { + let binding = chord.binding + let restored = HotkeyBinding(keyCode: binding.keyCode, carbonModifiers: binding.carbonModifiers) + XCTAssertEqual(restored, binding, "round trip lost \(chord.rawValue)") + } + } +} + +// MARK: - Arming plan + +final class HotkeyPlanTests: XCTestCase { + private let targets: Set = ["com.example.terminal", "com.example.console"] + + private func plan( + frontmost: String? = "com.example.terminal", + clipboardHasImage: Bool = true, + accessibilityTrusted: Bool = true, + local: Bool = true, + broadcast: Bool = false, + broadcastBinding: HotkeyBinding = BroadcastHotkey.cmdOptV.binding + ) -> [HotkeyAction: HotkeyBinding] { + HotkeyPlan.desired( + frontmostBundleID: frontmost, + targetBundleIDs: targets, + clipboardHasImage: clipboardHasImage, + accessibilityTrusted: accessibilityTrusted, + localPasteEnabled: local, + broadcastEnabled: broadcast, + broadcastBinding: broadcastBinding) + } + + // The gate cases below are the guardrail against the failure mode that + // makes a registered chord dangerous: holding ⌘V when CopyCat would not + // have acted means ⌘V is dead everywhere with no way to pass it through. + func testHoldsNothingWhenNoAppIsFrontmost() { + XCTAssertTrue(plan(frontmost: nil).isEmpty) + } + + func testHoldsNothingWhenFrontmostIsNotATarget() { + XCTAssertTrue(plan(frontmost: "com.example.browser").isEmpty) + } + + func testHoldsNothingWhenClipboardHasNoImage() { + XCTAssertTrue(plan(clipboardHasImage: false).isEmpty) + } + + func testHoldsNothingWhenBothChordsAreDisabled() { + XCTAssertTrue(plan(local: false, broadcast: false).isEmpty) + } + + // Carbon registers a chord with no TCC grant at all, but the paste it + // leads to can't type anything without one. Arming there would swallow ⌘V + // and produce nothing — the one outcome worse than not arming. + func testHoldsNothingWithoutAccessibility() { + XCTAssertTrue(plan(accessibilityTrusted: false).isEmpty) + XCTAssertTrue(plan(accessibilityTrusted: false, broadcast: true).isEmpty) + } + + func testHoldsLocalPasteWhenGateIsOpen() { + XCTAssertEqual(plan(), [.localPaste: .localPaste]) + } + + func testHoldsBothWhenChordsDiffer() { + let expected: [HotkeyAction: HotkeyBinding] = [ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ] + XCTAssertEqual(plan(broadcast: true), expected) + } + + func testHoldsOnlyBroadcastWhenLocalPasteIsDisabled() { + XCTAssertEqual( + plan(local: false, broadcast: true), + [.broadcast: BroadcastHotkey.cmdOptV.binding]) + } + + // Carbon rejects a second registration of the same chord, so a collision + // has to collapse to one entry — and broadcast is the one that wins. + func testBroadcastWinsWhenBothChordsAreCommandV() { + let result = plan(local: true, broadcast: true, broadcastBinding: BroadcastHotkey.cmdV.binding) + XCTAssertEqual(result, [.broadcast: HotkeyBinding.localPaste]) + XCTAssertNil(result[.localPaste]) + } +} + +// MARK: - Manager lifecycle + +@MainActor +final class HotkeyManagerTests: XCTestCase { + private func makeManager() -> (HotkeyManager, FakeRegistrar) { + let registrar = FakeRegistrar() + return (HotkeyManager(registrar: registrar), registrar) + } + + func testApplyRegistersDesiredChords() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + + XCTAssertEqual(registrar.calls, [ + .register(.localPaste, keyCode: 9, carbonModifiers: UInt32(cmdKey)), + ]) + XCTAssertEqual(manager.activeBindings, [.localPaste: .localPaste]) + } + + func testReapplyingTheSamePlanTouchesNothing() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + registrar.reset() + + manager.apply([.localPaste: .localPaste], reason: "test") + XCTAssertTrue(registrar.calls.isEmpty) + } + + func testChangingAChordReplacesTheRegistration() { + let (manager, registrar) = makeManager() + manager.apply([.broadcast: BroadcastHotkey.cmdOptV.binding], reason: "test") + registrar.reset() + + manager.apply([.broadcast: BroadcastHotkey.cmdShiftV.binding], reason: "test") + + XCTAssertEqual(registrar.calls, [ + .unregister(.broadcast), + .register(.broadcast, keyCode: 9, carbonModifiers: UInt32(cmdKey) | UInt32(shiftKey)), + ]) + XCTAssertEqual(manager.activeBindings, [.broadcast: BroadcastHotkey.cmdShiftV.binding]) + } + + func testDisablingAChordUnregistersIt() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + registrar.reset() + + manager.apply([:], reason: "test") + + XCTAssertEqual(registrar.calls, [.unregister(.localPaste)]) + XCTAssertTrue(manager.activeBindings.isEmpty) + XCTAssertTrue(registrar.live.isEmpty) + } + + // A chord handed from one action to another must be released first; + // claiming it while the old registration is live fails in Carbon. + func testChordMovingBetweenActionsIsReleasedBeforeItIsClaimed() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + registrar.reset() + + manager.apply([.broadcast: HotkeyBinding.localPaste], reason: "test") + + XCTAssertEqual(registrar.calls, [ + .unregister(.localPaste), + .register(.broadcast, keyCode: 9, carbonModifiers: UInt32(cmdKey)), + ]) + XCTAssertEqual(registrar.live, [.broadcast]) + } + + func testAllReleasesPrecedeAllClaimsWhenEveryChordChanges() { + let (manager, registrar) = makeManager() + manager.apply([ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ], reason: "test") + registrar.reset() + + manager.apply([ + .localPaste: HotkeyBinding(keyCode: 8, modifiers: CGEventFlags.maskCommand.rawValue), + .broadcast: BroadcastHotkey.cmdCtrlV.binding, + ], reason: "test") + + let lastRelease = try? XCTUnwrap(registrar.lastUnregisterIndex) + let firstClaim = try? XCTUnwrap(registrar.firstRegisterIndex) + XCTAssertNotNil(lastRelease) + XCTAssertNotNil(firstClaim) + XCTAssertLessThan(lastRelease ?? .max, firstClaim ?? .min) + } + + func testFailedRegistrationIsSurfacedAndNotRecordedAsActive() { + let (manager, registrar) = makeManager() + registrar.failures[.localPaste] = OSStatus(-9878) + + manager.apply([.localPaste: .localPaste], reason: "test") + + XCTAssertEqual(manager.lastFailure?.status, OSStatus(-9878)) + XCTAssertTrue(manager.activeBindings.isEmpty) + } + + func testOneFailedChordDoesNotDiscardTheOtherOne() { + let (manager, registrar) = makeManager() + registrar.failures[.broadcast] = OSStatus(-9878) + + manager.apply([ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ], reason: "test") + + XCTAssertEqual(manager.activeBindings, [.localPaste: .localPaste]) + XCTAssertEqual(manager.lastFailure?.status, OSStatus(-9878)) + } + + // A failed claim leaves nothing recorded, so the next apply must retry it + // rather than treat the chord as already held. + func testFailedRegistrationIsRetriedOnTheNextApply() { + let (manager, registrar) = makeManager() + registrar.failures[.localPaste] = OSStatus(-9878) + manager.apply([.localPaste: .localPaste], reason: "test") + + registrar.failures = [:] + registrar.reset() + manager.apply([.localPaste: .localPaste], reason: "test") + + XCTAssertEqual(registrar.registerCount, 1) + XCTAssertNil(manager.lastFailure) + XCTAssertEqual(manager.activeBindings, [.localPaste: .localPaste]) + } + + // The failure outlives the plan it belongs to only if nobody prunes it: + // once the chord is disabled, `desired == registered` short-circuits apply + // and a retained status would report CopyCat as inert forever. + func testFailureIsClearedWhenTheActionLeavesThePlan() { + let (manager, registrar) = makeManager() + registrar.failures[.localPaste] = OSStatus(-9878) + manager.apply([.localPaste: .localPaste], reason: "test") + XCTAssertEqual(manager.lastFailure?.status, OSStatus(-9878)) + + manager.apply([:], reason: "test") + + XCTAssertNil(manager.lastFailure) + } + + // The menu names the chord that is unavailable, so the failure has to carry + // the binding and not just a status code. + func testFailureIdentifiesTheChordThatCouldNotBeClaimed() { + let (manager, registrar) = makeManager() + registrar.failures[.broadcast] = OSStatus(-9878) + + manager.apply([.broadcast: BroadcastHotkey.cmdOptV.binding], reason: "test") + + XCTAssertEqual(manager.lastFailure?.action, .broadcast) + XCTAssertEqual(manager.lastFailure?.binding, BroadcastHotkey.cmdOptV.binding) + } + + func testFailureForOneActionSurvivesAnotherActionLeavingThePlan() { + let (manager, registrar) = makeManager() + registrar.failures[.broadcast] = OSStatus(-9878) + manager.apply([ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ], reason: "test") + + manager.apply([.broadcast: BroadcastHotkey.cmdOptV.binding], reason: "test") + + XCTAssertEqual(manager.lastFailure?.status, OSStatus(-9878)) + } + + func testReleaseAllClearsBookkeeping() { + let (manager, registrar) = makeManager() + registrar.failures[.broadcast] = OSStatus(-9878) + manager.apply([ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ], reason: "test") + + manager.releaseAll() + + XCTAssertTrue(manager.activeBindings.isEmpty) + XCTAssertTrue(registrar.live.isEmpty) + XCTAssertTrue(registrar.calls.contains(.unregisterAll)) + XCTAssertNil(manager.lastFailure) + } + + func testInvalidateAlsoTearsDownTheEventHandler() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + + manager.invalidate() + + XCTAssertTrue(manager.activeBindings.isEmpty) + XCTAssertTrue(registrar.calls.contains(.invalidate)) + } + + // A refused drop means the system still holds the chord. Recording it as + // released would claim ⌘V is free while it is in fact dead everywhere. + func testFailedUnregisterKeepsTheChordRecordedAndRetriesIt() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + registrar.unregisterFailures[.localPaste] = OSStatus(-9874) + registrar.reset() + + manager.apply([:], reason: "test") + XCTAssertEqual(manager.activeBindings, [.localPaste: .localPaste]) + + registrar.unregisterFailures = [:] + registrar.reset() + manager.apply([:], reason: "test") + + XCTAssertEqual(registrar.calls, [.unregister(.localPaste)]) + XCTAssertTrue(manager.activeBindings.isEmpty) + } + + func testReleaseDropsOnlyTheRequestedChord() { + let (manager, registrar) = makeManager() + manager.apply([ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ], reason: "test") + registrar.reset() + + XCTAssertTrue(manager.release(.localPaste, reason: "test")) + + XCTAssertEqual(registrar.calls, [.unregister(.localPaste)]) + XCTAssertEqual(manager.activeBindings, [.broadcast: BroadcastHotkey.cmdOptV.binding]) + XCTAssertEqual(registrar.live, [.broadcast]) + } + + func testReleaseReportsFailureAndKeepsTheChord() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + registrar.unregisterFailures[.localPaste] = OSStatus(-9874) + + XCTAssertFalse(manager.release(.localPaste, reason: "test")) + XCTAssertEqual(manager.activeBindings, [.localPaste: .localPaste]) + } + + func testReleasedChordIsReArmedByTheNextApply() { + let (manager, registrar) = makeManager() + manager.apply([.localPaste: .localPaste], reason: "test") + _ = manager.release(.localPaste, reason: "test") + registrar.reset() + + manager.apply([.localPaste: .localPaste], reason: "test") + + XCTAssertEqual(registrar.calls, [ + .register(.localPaste, keyCode: 9, carbonModifiers: UInt32(cmdKey)), + ]) + XCTAssertEqual(manager.activeBindings, [.localPaste: .localPaste]) + } + + func testFiringForwardsTheActionThroughTheManager() { + let (manager, registrar) = makeManager() + var fired: [HotkeyAction] = [] + manager.onFire = { fired.append($0) } + + registrar.onFire?(.broadcast) + registrar.onFire?(.localPaste) + + XCTAssertEqual(fired, [.broadcast, .localPaste]) + } +} + +// MARK: - Fail-closed registration + +@MainActor +final class SystemCarbonHotKeyRegistrarTests: XCTestCase { + // Only the failing installer is exercised. A successful one would go on to + // claim a real chord for the whole login session — including whatever is + // running these tests — which is exactly what the fake registrar exists to + // avoid elsewhere. + func testRegistrationBailsWhenTheHandlerCannotBeInstalled() { + let registrar = SystemCarbonHotKeyRegistrar { _ in OSStatus(-9868) } + + let status = registrar.register(.localPaste, keyCode: 9, carbonModifiers: UInt32(cmdKey)) + + XCTAssertEqual(status, OSStatus(-9868), "the installer's status must reach the caller") + } + + // An installer that claims success without leaving a handler behind is the + // same hazard wearing a noErr: a claimed chord with nothing listening + // swallows the keystroke and does nothing with it. + func testRegistrationBailsWhenTheInstallerLeavesNoHandler() { + let registrar = SystemCarbonHotKeyRegistrar { _ in noErr } + + let status = registrar.register(.localPaste, keyCode: 9, carbonModifiers: UInt32(cmdKey)) + + XCTAssertEqual(status, OSStatus(paramErr)) + XCTAssertEqual(registrar.unregister(.localPaste), noErr, "nothing should have been recorded as claimed") + } +} + +// MARK: - Fire decision + +final class HotkeyFireTests: XCTestCase { + private let targets: Set = ["com.example.terminal"] + + private func outcome( + frontmost: String? = "com.example.terminal", + clipboardHasImage: Bool = true + ) -> HotkeyFireOutcome { + HotkeyFire.outcome( + frontmostBundleID: frontmost, + targetBundleIDs: targets, + clipboardHasImage: clipboardHasImage) + } + + func testActsWhenTheGateStillHolds() { + XCTAssertEqual(outcome(), .act) + } + + // Both bail cases are races against the arming gate, and both mean the + // keystroke has already been swallowed — so neither can end in silence. + func testPassesTheKeystrokeBackWhenFrontmostIsNoLongerATarget() { + XCTAssertEqual(outcome(frontmost: "com.example.browser"), .passThrough(.frontmostNotATarget)) + XCTAssertEqual(outcome(frontmost: nil), .passThrough(.frontmostNotATarget)) + } + + func testPassesTheKeystrokeBackWhenTheImageIsGone() { + XCTAssertEqual(outcome(clipboardHasImage: false), .passThrough(.clipboardImageGone)) + } +} + +// MARK: - Shared paste cooldown + +@MainActor +final class PasteCooldownTests: XCTestCase { + func testFirstClaimIsAlwaysAllowed() { + XCTAssertTrue(PasteCooldown(window: 1).claim(.localPaste, now: 0)) + } + + // The two paths that can see one physical ⌘V — the registered chord and + // the IOHID sensor — must not each type the path. + func testSecondClaimInsideTheWindowIsRejected() { + let cooldown = PasteCooldown(window: 1) + XCTAssertTrue(cooldown.claim(.localPaste, now: 100)) + XCTAssertFalse(cooldown.claim(.localPaste, now: 100.2)) + } + + func testClaimIsAllowedAgainAfterTheWindow() { + let cooldown = PasteCooldown(window: 1) + XCTAssertTrue(cooldown.claim(.localPaste, now: 100)) + XCTAssertFalse(cooldown.claim(.localPaste, now: 100.5)) + XCTAssertTrue(cooldown.claim(.localPaste, now: 101.6)) + } + + // Only same-action deliveries can be one keystroke seen twice. A local + // paste followed by a broadcast is two deliberate presses and both land. + func testAnotherActionIsNotSuppressedByTheWindow() { + let cooldown = PasteCooldown(window: 1) + XCTAssertTrue(cooldown.claim(.localPaste, now: 100)) + XCTAssertTrue(cooldown.claim(.broadcast, now: 100.2)) + } + + func testEachActionKeepsItsOwnWindow() { + let cooldown = PasteCooldown(window: 1) + XCTAssertTrue(cooldown.claim(.localPaste, now: 100)) + XCTAssertTrue(cooldown.claim(.broadcast, now: 100.2)) + XCTAssertFalse(cooldown.claim(.localPaste, now: 100.3)) + XCTAssertFalse(cooldown.claim(.broadcast, now: 100.4)) + } + + // A rejected claim must not slide the window forward, or a repeating key + // could keep the next deliberate paste out indefinitely. + func testRejectedClaimsDoNotExtendTheWindow() { + let cooldown = PasteCooldown(window: 1) + XCTAssertTrue(cooldown.claim(.localPaste, now: 100)) + XCTAssertFalse(cooldown.claim(.localPaste, now: 100.9)) + XCTAssertTrue(cooldown.claim(.localPaste, now: 101.5)) + } +} diff --git a/Tests/CopyCatTests/PasteHandlerTests.swift b/Tests/CopyCatTests/PasteHandlerTests.swift new file mode 100644 index 0000000..f5653e4 --- /dev/null +++ b/Tests/CopyCatTests/PasteHandlerTests.swift @@ -0,0 +1,331 @@ +import Carbon.HIToolbox +import XCTest +@testable import CopyCat + +/// Stands in for the frontmost app, the clipboard, the Accessibility grant and +/// the event-posting machinery, so the arming gate can be driven through every +/// state without touching the running session. +@MainActor +private final class EnvironmentStub { + var frontmost: String? + var clipboardHasImage = true + var clipboardChangeCount = 0 + var accessibilityTrusted = true + + private(set) var postedChords: [HotkeyBinding] = [] + /// Actions that reached the paste itself, recorded instead of dispatched: + /// a real paste would read the running session's clipboard and, for + /// broadcast, reach for the network. + private(set) var pastedActions: [HotkeyAction] = [] + /// Runs inside the post, so a test can inspect what is still registered at + /// the exact moment the keystroke goes back out. + var onPost: ((HotkeyBinding) -> Void)? + + /// Per-instance so nothing here depends on how fast the suite runs. + let cooldown = PasteCooldown(window: 1) + + var environment: HotkeyEnvironment { + HotkeyEnvironment( + frontmostBundleID: { self.frontmost }, + clipboardHasImage: { self.clipboardHasImage }, + clipboardChangeCount: { self.clipboardChangeCount }, + accessibilityTrusted: { self.accessibilityTrusted }, + postChord: { binding in + self.postedChords.append(binding) + self.onPost?(binding) + }, + pasteCooldown: cooldown, + performPaste: { self.pastedActions.append($0) }) + } +} + +@MainActor +final class PasteHandlerTests: XCTestCase { + /// `terminal` is a bundle ID from the shipped target list, read out of + /// Settings rather than written into it: `registerDefaults` populates only + /// the volatile registration domain, so these tests never touch stored + /// preferences. + private func makeHandler() throws -> (PasteHandler, HotkeyManager, FakeRegistrar, EnvironmentStub, String) { + Settings.registerDefaults() + let terminal = try XCTUnwrap(Settings.targetBundleIDs.sorted().first) + let registrar = FakeRegistrar() + let stub = EnvironmentStub() + let hotkeys = HotkeyManager(registrar: registrar) + let handler = PasteHandler(hotkeys: hotkeys, environment: stub.environment) + return (handler, hotkeys, registrar, stub, terminal) + } + + // MARK: - Gate + + func testArmsTheLocalChordWhenEveryGateInputLinesUp() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + + handler.reconcile(reason: "test") + + XCTAssertEqual(registrar.live, [.localPaste]) + XCTAssertEqual(handler.hotkeyStatus, .on) + } + + func testHoldsNothingWhileAnotherAppIsFrontmost() throws { + let (handler, _, registrar, stub, _) = try makeHandler() + stub.frontmost = "com.example.browser" + + handler.reconcile(reason: "test") + + XCTAssertTrue(registrar.live.isEmpty) + } + + func testHoldsNothingWhenTheClipboardHasNoImage() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + stub.clipboardHasImage = false + + handler.reconcile(reason: "test") + + XCTAssertTrue(registrar.live.isEmpty) + } + + // Without the grant a chord would swallow ⌘V and type nothing, so the gate + // stays shut — and the header has to say why, distinctly from "off". + func testHoldsNothingAndSaysSoWithoutAccessibility() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + stub.accessibilityTrusted = false + + handler.reconcile(reason: "test") + + XCTAssertTrue(registrar.live.isEmpty) + XCTAssertEqual(handler.hotkeyStatus, .needsAccessibility) + } + + // The grant is re-read on every reconcile precisely so that approving it + // in System Settings takes effect without relaunching CopyCat. + func testGrantLandingArmsTheChordWithoutRestart() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + stub.accessibilityTrusted = false + handler.reconcile(reason: "test") + XCTAssertTrue(registrar.live.isEmpty) + + stub.accessibilityTrusted = true + handler.reconcile(reason: "test") + + XCTAssertEqual(registrar.live, [.localPaste]) + XCTAssertEqual(handler.hotkeyStatus, .on) + } + + // A chord another app already owns must not read as "off" — that is the + // state the user gets by flipping the toggle themselves, and it sends them + // looking in the wrong place. + func testStatusNamesTheChordWhenRegistrationFails() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + registrar.failures[.localPaste] = OSStatus(-9878) + stub.frontmost = terminal + + handler.reconcile(reason: "test") + + XCTAssertEqual( + handler.hotkeyStatus, + .unavailable(chord: HotkeyBinding.localPaste.displayString)) + XCTAssertNotEqual(handler.hotkeyStatus, .off) + } + + // Precedence: a missing grant explains every chord at once, so it outranks + // an individual chord that couldn't be claimed. + func testMissingAccessibilityOutranksARegistrationFailure() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + registrar.failures[.localPaste] = OSStatus(-9878) + stub.frontmost = terminal + handler.reconcile(reason: "test") + + stub.accessibilityTrusted = false + + XCTAssertEqual(handler.hotkeyStatus, .needsAccessibility) + } + + // MARK: - Poll lifecycle + + func testClipboardPollRunsOnlyWhileATargetIsFrontmost() throws { + let (handler, _, _, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + XCTAssertTrue(handler.isPollingClipboard) + + stub.frontmost = "com.example.browser" + handler.reconcile(reason: "test") + XCTAssertFalse(handler.isPollingClipboard) + } + + func testClipboardPollSurvivesAReconcileWithinTheSameApp() throws { + let (handler, _, _, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + handler.reconcile(reason: "test") + + XCTAssertTrue(handler.isPollingClipboard) + } + + // MARK: - Passthrough on bail + + // The chord is armed from state sampled up to a poll interval earlier, so + // it can fire after the gate closed. The keystroke is already gone by + // then: bailing without handing it back destroys the user's ⌘V. + func testHandsTheKeystrokeBackWhenFrontmostChangedFirst() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + stub.frontmost = "com.example.browser" + registrar.reset() + + handler.fire(.localPaste) + + XCTAssertEqual(stub.postedChords, [HotkeyBinding.localPaste]) + XCTAssertEqual(registrar.calls, [.unregister(.localPaste)]) + } + + func testHandsTheKeystrokeBackWhenTheImageVanishedFirst() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + stub.clipboardHasImage = false + registrar.reset() + + handler.fire(.localPaste) + + XCTAssertEqual(stub.postedChords, [HotkeyBinding.localPaste]) + } + + // The loop guard in one assertion: the re-posted event carries the same + // modifiers the hot key matches on, so the chord must already be gone by + // the time it goes out. + func testChordIsReleasedBeforeTheKeystrokeIsPosted() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + stub.frontmost = "com.example.browser" + + var liveAtPost: Set? + stub.onPost = { _ in liveAtPost = registrar.live } + + handler.fire(.localPaste) + + XCTAssertEqual(liveAtPost, [], "posting while the chord is still claimed would re-enter the handler") + } + + func testPassthroughIsAbandonedWhenTheChordCannotBeReleased() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + registrar.unregisterFailures[.localPaste] = OSStatus(-9874) + stub.frontmost = "com.example.browser" + + handler.fire(.localPaste) + + XCTAssertTrue(stub.postedChords.isEmpty) + } + + func testNothingIsPostedWhenNoChordWasHeld() throws { + let (handler, _, registrar, stub, _) = try makeHandler() + stub.frontmost = "com.example.browser" + + handler.fire(.localPaste) + + XCTAssertTrue(stub.postedChords.isEmpty) + XCTAssertTrue(registrar.calls.isEmpty) + } + + // Nothing to hand back for the fired action, but the gate is still closed + // for the chords that *are* armed. + func testSiblingsAreStillReleasedWhenTheFiredChordIsNotHeld() throws { + let (handler, hotkeys, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + hotkeys.apply([.broadcast: BroadcastHotkey.cmdOptV.binding], reason: "test") + stub.frontmost = "com.example.browser" + registrar.reset() + + handler.fire(.localPaste) + + XCTAssertTrue(registrar.live.isEmpty) + XCTAssertTrue(stub.postedChords.isEmpty, "a chord that was never held can't be handed back") + } + + // The gate is one decision for all chords: when it goes stale, a sibling + // left armed keeps swallowing keystrokes for a window that already closed. + func testPassthroughReleasesEveryArmedChordNotJustTheFiredOne() throws { + let (handler, hotkeys, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + // Armed directly: reaching this through reconcile would need the + // broadcast toggle in stored preferences, and the rule under test + // doesn't depend on how the chords came to be armed. + hotkeys.apply([ + .localPaste: .localPaste, + .broadcast: BroadcastHotkey.cmdOptV.binding, + ], reason: "test") + stub.frontmost = "com.example.browser" + registrar.reset() + + handler.fire(.localPaste) + + XCTAssertTrue(registrar.live.isEmpty, "the gate is closed for every chord, not just the fired one") + XCTAssertTrue(hotkeys.activeBindings.isEmpty) + XCTAssertEqual( + stub.postedChords, [HotkeyBinding.localPaste], + "only the swallowed chord is handed back — the sibling was never pressed") + } + + // MARK: - Paste dispatch + + func testFiringInsideTheGatePastes() throws { + let (handler, _, _, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + + handler.fire(.localPaste) + + XCTAssertEqual(stub.pastedActions, [.localPaste]) + XCTAssertTrue(stub.postedChords.isEmpty, "acting on the chord must not also hand the keystroke back") + } + + // One keystroke can reach both the chord and the Secure Input sensor, so a + // repeat of the same action inside the window is dropped. + func testRepeatOfTheSameActionIsSuppressed() throws { + let (handler, _, _, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + + handler.fire(.localPaste) + handler.fire(.localPaste) + + XCTAssertEqual(stub.pastedActions, [.localPaste]) + } + + // Different chords are different presses; suppressing the second would + // silently drop a paste the user deliberately asked for. + func testADifferentActionIsNotSuppressedByTheWindow() throws { + let (handler, _, _, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + + handler.fire(.localPaste) + handler.fire(.broadcast) + + XCTAssertEqual(stub.pastedActions, [.localPaste, .broadcast]) + } + + // Re-arming is left to the reconcile path, so a released chord comes back + // as soon as the conditions that justify it do. + func testReleasedChordIsReArmedOnceTheGateReopens() throws { + let (handler, _, registrar, stub, terminal) = try makeHandler() + stub.frontmost = terminal + handler.reconcile(reason: "test") + stub.frontmost = "com.example.browser" + handler.fire(.localPaste) + XCTAssertTrue(registrar.live.isEmpty) + + stub.frontmost = terminal + handler.reconcile(reason: "test") + + XCTAssertEqual(registrar.live, [.localPaste]) + } +} From caaacb7a9b8f5cf33669e72670fcdd9a1f7afbc4 Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:46:08 -0400 Subject: [PATCH 2/3] Run CI on a Swift 6.2 toolchain and exercise the tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macos-15 carries Xcode 16, whose Swift can't parse a 6.2 manifest, so the build broke the moment the package moved to 6.2. Runner images ship one Xcode major apiece, which makes the OS label the toolchain pin. Also run swift test: the suite covers the rule that decides when ⌘V is claimed system-wide, and a build-only job proves none of it. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb4dab2..7b7b66d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,11 @@ on: jobs: build: - runs-on: macos-15 + # Pinned rather than macos-latest: each runner image carries exactly one + # Xcode major, and the package needs Swift 6.2 for isolated deinit. On + # macos-15 (Xcode 16) the manifest doesn't even parse. + runs-on: macos-26 steps: - uses: actions/checkout@v4 - run: swift build + - run: swift test From 75c6673c48fe14d76f43a1a4ca0bc0b3727c134e Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:49:55 -0400 Subject: [PATCH 3/3] Bump actions/checkout to v7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v4 targets Node 20, which the runners now force onto Node 24 and warn about. v5 is the release that made that move deliberate; v7 is the current line and clears the same warning. Its one behavior change — refusing to check out fork PRs under pull_request_target and workflow_run — doesn't reach this workflow, which uses pull_request. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b7b66d..6596d6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,6 @@ jobs: # macos-15 (Xcode 16) the manifest doesn't even parse. runs-on: macos-26 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - run: swift build - run: swift test