diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cb4dab2..6596d6a 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
+ - uses: actions/checkout@v7
- run: swift build
+ - run: swift test
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 @@
-
+
@@ -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])
+ }
+}