Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 83 additions & 20 deletions Sources/CopyCat/CopyCatApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,27 @@ struct CopyCatApp: App {
// synchronously — that combination produces a tight transaction loop.
@AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true

// Resolve once at startup. SwiftUI's MenuBarExtra(_:image:) form expects
// an asset-catalog name, which we don't have — feeding NSImage directly
// through the custom-label form sidesteps that lookup.
private let menuBarIcon: NSImage = {
var body: some Scene {
// Settings is hosted in an AppDelegate-owned NSWindowController, not
// a SwiftUI Settings scene. showSettingsWindow: dispatch is unreliable
// for LSUIElement apps — when the menu bar icon is hidden there's no
// key window in the responder chain, so applicationShouldHandleReopen
// can't surface it.
MenuBarExtra(isInserted: $showMenuBarIcon) {
CopyCatMenu()
.environment(\.updaterController, appDelegate.updaterController)
} label: {
MenuBarLabel()
}
}
}

// Resolved once. SwiftUI's MenuBarExtra(_:image:) form expects an
// asset-catalog name, which we don't have — feeding NSImage directly through
// the custom-label form sidesteps that lookup.
@MainActor
private enum MenuBarIconFactory {
static let normal: NSImage = {
if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "pdf"),
let image = NSImage(contentsOf: url) {
image.size = NSSize(width: 18, height: 18)
Expand All @@ -29,19 +46,38 @@ struct CopyCatApp: App {
return fallback
}()

var body: some Scene {
// Settings is hosted in an AppDelegate-owned NSWindowController, not
// a SwiftUI Settings scene. showSettingsWindow: dispatch is unreliable
// for LSUIElement apps — when the menu bar icon is hidden there's no
// key window in the responder chain, so applicationShouldHandleReopen
// can't surface it.
MenuBarExtra(isInserted: $showMenuBarIcon) {
CopyCatMenu()
.environment(\.updaterController, appDelegate.updaterController)
} label: {
Image(nsImage: menuBarIcon)
.accessibilityLabel("CopyCat")
// Same paw with an exclamation badge in the corner: the persistent,
// glanceable "paste is broken" signal while Secure Input blocks the tap.
static let blocked: NSImage = {
let base = normal
let size = base.size == .zero ? NSSize(width: 18, height: 18) : base.size
let image = NSImage(size: size, flipped: false) { rect in
base.draw(in: rect)
let badge = NSRect(x: rect.maxX - 9, y: rect.minY, width: 9, height: 9)
// Punch a ring around the badge so it reads against the base
// glyph at menu-bar size (template images are alpha-only).
NSGraphicsContext.current?.compositingOperation = .destinationOut
NSColor.black.setFill()
NSBezierPath(ovalIn: badge.insetBy(dx: -1.5, dy: -1.5)).fill()
NSGraphicsContext.current?.compositingOperation = .sourceOver
if let symbol = NSImage(systemSymbolName: "exclamationmark.circle.fill", accessibilityDescription: nil) {
symbol.draw(in: badge)
} else {
NSBezierPath(ovalIn: badge).fill()
}
return true
}
image.isTemplate = true
return image
}()
}

private struct MenuBarLabel: View {
@ObservedObject private var status = StatusModel.shared

var body: some View {
Image(nsImage: status.secureInputAlerting ? MenuBarIconFactory.blocked : MenuBarIconFactory.normal)
.accessibilityLabel(status.secureInputAlerting ? "CopyCat — paste blocked" : "CopyCat")
}
}

Expand Down Expand Up @@ -82,6 +118,14 @@ private struct CopyCatMenu: View {
NSWorkspace.shared.open(url)
}
}
// The paste-attempt sensor (toast at the exact moment ⌘V is
// pressed while blocked) needs Input Monitoring; hide the item
// once granted since the sensor then arms automatically.
if !SecureInputWatcher.shared.sensorAccessGranted {
Button("Enable paste-attempt alerts…") {
SecureInputWatcher.shared.requestSensorAccess()
}
}
}

Divider()
Expand Down Expand Up @@ -178,10 +222,23 @@ private struct StatusHeader: View {

// Secure Input silently blocks the tap for the whole session, so a
// green "Tap on" alone would be misleading — call out the culprit.
if let blocker = status.secureInputBlocker {
Text("⚠ Blocked by Secure Input (\(blocker))")
.font(.caption)
.foregroundStyle(.orange)
// 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 {
if status.secureInputAlerting {
Text(secureInput.menuLabel)
.font(.caption)
.foregroundStyle(.orange)
if let action = secureInput.action {
Button(action.label) {
SecureInputActions.perform(action)
}
}
} else {
Text(secureInput.menuLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}

if store.enableBroadcast {
Expand Down Expand Up @@ -308,10 +365,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

pasteHandler = PasteHandler()
pasteHandler?.start()

SecureInputWatcher.shared.tapEnabledProvider = { [weak self] in
self?.pasteHandler?.isTapEnabled ?? false
}
SecureInputWatcher.shared.start()
}

func applicationWillTerminate(_ notification: Notification) {
Log.app.info("CopyCat terminating")
SecureInputWatcher.shared.stop()
pasteHandler?.stop()
}

Expand Down
8 changes: 7 additions & 1 deletion Sources/CopyCat/HotkeyBinding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ struct HotkeyBinding: Equatable, Sendable {

func matches(keyCode: Int64, flags: CGEventFlags) -> Bool {
guard Int(keyCode) == self.keyCode else { return false }
return (flags.rawValue & modifierMask) == modifiers
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 {
(flags.rawValue & modifierMask) == modifiers
}

var displayString: String {
Expand Down
1 change: 1 addition & 0 deletions Sources/CopyCat/Logger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ enum Log {
static let cmdV = AppLogger(category: "Local")
static let cmdOptV = AppLogger(category: "Broadcast")
static let watchdog = AppLogger(category: "Watchdog")
static let secure = AppLogger(category: "SecureInput")
}

struct AppLogger {
Expand Down
19 changes: 5 additions & 14 deletions Sources/CopyCat/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ enum Notifier {
}
}

static func secureInputBlocked(_ owner: SecureInput.Owner?) {
static func secureInputBlocked(_ presentation: SecureInputPresentation) {
guard available else { return }
let content = UNMutableNotificationContent()
content.title = "CopyCat — paste blocked"
content.body = blockedBody(owner)
content.title = presentation.title
// Advice only — banners get ~4 visible lines and the title already
// names the culprit; the full cause stays in the menu and log.
content.body = presentation.advice
content.sound = .default

let request = UNNotificationRequest(identifier: secureInputID, content: content, trigger: nil)
Expand All @@ -38,15 +40,4 @@ enum Notifier {
guard available else { return }
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [secureInputID])
}

private static func blockedBody(_ owner: SecureInput.Owner?) -> String {
let restore = HotkeyBinding.localPaste.displayString
if let owner, owner.isOrphaned {
return "An orphaned Secure Input lock (owner pid \(owner.pid) has exited) is suppressing \(restore) for every app. Log out and back in to clear it."
}
if let owner {
return "\(owner.description) is holding Secure Input, which suppresses \(restore) for every app. Quit or refocus it, or finish its password prompt."
}
return "Secure Input is suppressing \(restore) for every app. Log out and back in if it persists."
}
}
115 changes: 115 additions & 0 deletions Sources/CopyCat/PasteAttemptSensor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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
// original event still reaches the frontmost app untouched. Non-chord keys
// are discarded in the callback; nothing is stored or logged.
//
// Reports the full modifier set so the caller can distinguish the exact local
// paste chord (degradable) from broadcast variants (explain-only).
//
// Requires the Input Monitoring TCC grant (Accessibility approval typically
// satisfies it). The watcher arms this only while a blockage episode is live,
// so HID monitoring is off in normal operation.
@MainActor
final class PasteAttemptSensor {
private var manager: IOHIDManager?
/// HID usages (0xE0–0xE7) of modifiers currently held down.
private var downModifiers: Set<Int> = []
private let onPasteAttempt: (CGEventFlags) -> Void

init(onPasteAttempt: @escaping (CGEventFlags) -> Void) {
self.onPasteAttempt = onPasteAttempt
}

static var accessGranted: Bool {
IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) == kIOHIDAccessTypeGranted
}

/// Triggers the one-time OS consent prompt; returns whether access is
/// granted right now (a fresh prompt returns false until the user acts).
static func requestAccess() -> Bool {
IOHIDRequestAccess(kIOHIDRequestTypeListenEvent)
}

func start() {
guard manager == nil else { return }
guard Self.accessGranted else { return }

let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let match: [[String: Any]] = [[
kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop,
kIOHIDDeviceUsageKey: kHIDUsage_GD_Keyboard,
]]
IOHIDManagerSetDeviceMatchingMultiple(manager, match as CFArray)

let context = Unmanaged.passUnretained(self).toOpaque()
IOHIDManagerRegisterInputValueCallback(manager, { context, _, _, value in
guard let context else { return }
let sensor = Unmanaged<PasteAttemptSensor>.fromOpaque(context).takeUnretainedValue()
// Scheduled on the main runloop, so the callback lands on main.
MainActor.assumeIsolated {
sensor.handle(value: value)
}
}, context)

IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
let rc = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
guard rc == kIOReturnSuccess else {
Log.secure.error("paste-attempt sensor: IOHIDManagerOpen failed (\(rc))")
IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
return
}
self.manager = manager
Log.secure.info("paste-attempt sensor armed")
}

func stop() {
guard let manager else { return }
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue)
self.manager = nil
downModifiers = []
Log.secure.info("paste-attempt sensor disarmed")
}

private func handle(value: IOHIDValue) {
let element = IOHIDValueGetElement(value)
guard IOHIDElementGetUsagePage(element) == UInt32(kHIDPage_KeyboardOrKeypad) else { return }
let usage = Int(IOHIDElementGetUsage(element))
let pressed = IOHIDValueGetIntegerValue(value) != 0

switch usage {
case kHIDUsage_KeyboardLeftControl...kHIDUsage_KeyboardRightGUI:
if pressed { downModifiers.insert(usage) } else { downModifiers.remove(usage) }
case kHIDUsage_KeyboardV:
if pressed && currentFlags().contains(.maskCommand) {
onPasteAttempt(currentFlags())
}
default:
break
}
}

private func currentFlags() -> CGEventFlags {
var flags = CGEventFlags()
for usage in downModifiers {
switch usage {
case kHIDUsage_KeyboardLeftControl, kHIDUsage_KeyboardRightControl:
flags.insert(.maskControl)
case kHIDUsage_KeyboardLeftShift, kHIDUsage_KeyboardRightShift:
flags.insert(.maskShift)
case kHIDUsage_KeyboardLeftAlt, kHIDUsage_KeyboardRightAlt:
flags.insert(.maskAlternate)
case kHIDUsage_KeyboardLeftGUI, kHIDUsage_KeyboardRightGUI:
flags.insert(.maskCommand)
default:
break
}
}
return flags
}
}
Loading
Loading