diff --git a/Sources/ClickLight/AppDelegate.swift b/Sources/ClickLight/AppDelegate.swift index ecef9fd..a87eac7 100644 --- a/Sources/ClickLight/AppDelegate.swift +++ b/Sources/ClickLight/AppDelegate.swift @@ -103,6 +103,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func appDidBecomeActive() { statusController.refresh() + retryEventTapIfNeeded() + } + + /// If the user granted Accessibility permission while the app was running, + /// the initial `CGEvent.tapCreate` failed silently and capture fell back to + /// the NSEvent monitor. Re-entering the app is the natural moment to retry + /// tap creation so full capture works without a restart. + private func retryEventTapIfNeeded() { + guard settingsStore.settings.isEnabled else { return } + guard permissions.isAccessibilityTrusted else { return } + guard !captureController.usesEventTap else { return } + captureController.refreshEnabledState() } @objc private func shortcutRecordingDidBegin() { diff --git a/Sources/ClickLight/ClickCaptureController.swift b/Sources/ClickLight/ClickCaptureController.swift index 0ee1340..d57b7bc 100644 --- a/Sources/ClickLight/ClickCaptureController.swift +++ b/Sources/ClickLight/ClickCaptureController.swift @@ -2,6 +2,7 @@ import Foundation protocol ClickEventCapturing: AnyObject { var statusLabel: String { get } + var usesEventTap: Bool { get } func start( laserPointerEnabled: Bool, @@ -25,6 +26,10 @@ final class ClickCaptureController { eventTap.statusLabel } + var usesEventTap: Bool { + eventTap.usesEventTap + } + func startIfEnabled() { guard settingsStore.settings.isEnabled else { return } eventTap.start( diff --git a/Sources/ClickLight/ClickEventTap.swift b/Sources/ClickLight/ClickEventTap.swift index 507463c..4d28b91 100644 --- a/Sources/ClickLight/ClickEventTap.swift +++ b/Sources/ClickLight/ClickEventTap.swift @@ -24,6 +24,10 @@ final class ClickEventTap: ClickEventCapturing { } } + var usesEventTap: Bool { + eventTap != nil + } + func start( laserPointerEnabled: Bool, liveKeyboardShortcutsEnabled: Bool, @@ -126,12 +130,13 @@ final class ClickEventTap: ClickEventCapturing { } globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: eventTypes) { event in + let location = NSEvent.mouseLocation if event.type == .keyDown, let shortcut = Self.keyboardShortcut(from: event) { - Self.post(shortcut: shortcut, timestamp: event.timestamp) + Self.post(shortcut: shortcut, location: location, timestamp: event.timestamp) return } guard let kind = ClickKind(event: event) else { return } - Self.post(kind: kind, timestamp: event.timestamp) + Self.post(kind: kind, location: location, timestamp: event.timestamp) } } @@ -151,7 +156,7 @@ final class ClickEventTap: ClickEventCapturing { } if type == .keyDown, let nsEvent = NSEvent(cgEvent: event), let shortcut = Self.keyboardShortcut(from: nsEvent) { - Self.post(shortcut: shortcut, timestamp: event.timestampSeconds) + Self.post(shortcut: shortcut, location: NSEvent.mouseLocation, timestamp: event.timestampSeconds) return Unmanaged.passUnretained(event) } @@ -159,28 +164,41 @@ final class ClickEventTap: ClickEventCapturing { return Unmanaged.passUnretained(event) } - Self.post(kind: kind, timestamp: event.timestampSeconds) + // Sample the pointer position synchronously at event time. Deferring + // the read to main-queue delivery would capture wherever the pointer + // happens to be then, and the fallback monitor would record different + // coordinates for the same physical click (breaking dedup). + Self.post(kind: kind, location: Self.appKitLocation(of: event), timestamp: event.timestampSeconds) return Unmanaged.passUnretained(event) } - private static func post(kind: ClickKind, timestamp: TimeInterval) { + /// Converts a CGEvent's Quartz location (top-left origin, primary display) + /// into AppKit screen coordinates (bottom-left origin), matching + /// `NSEvent.mouseLocation` so both capture channels agree. + private static func appKitLocation(of event: CGEvent) -> CGPoint { + let quartzPoint = event.location + let primaryDisplayHeight = CGDisplayPixelsHigh(CGMainDisplayID()) + return CGPoint(x: quartzPoint.x, y: CGFloat(primaryDisplayHeight) - quartzPoint.y) + } + + private static func post(kind: ClickKind, location: CGPoint, timestamp: TimeInterval) { DispatchQueue.main.async { let clickEvent = ClickEvent( kind: kind, - location: NSEvent.mouseLocation, + location: location, timestamp: timestamp ) NotificationCenter.default.post(name: Self.didReceiveClickEvent, object: ClickEventBox(clickEvent)) } } - private static func post(shortcut: HotKeyBinding, timestamp: TimeInterval) { + private static func post(shortcut: HotKeyBinding, location: CGPoint, timestamp: TimeInterval) { DispatchQueue.main.async { let shortcutEvent = KeyboardShortcutEvent( binding: shortcut, displayString: shortcut.displayString, - location: NSEvent.mouseLocation, + location: location, timestamp: timestamp ) NotificationCenter.default.post( diff --git a/Sources/ClickLight/ClickLightSettingsView.swift b/Sources/ClickLight/ClickLightSettingsView.swift index 3215534..512c49e 100644 --- a/Sources/ClickLight/ClickLightSettingsView.swift +++ b/Sources/ClickLight/ClickLightSettingsView.swift @@ -36,7 +36,7 @@ struct ClickLightSettingsView: View { .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) - ClickPreviewPad(settings: viewModel.settings, activityStore: activityStore) + ClickPreviewPad(settings: viewModel.settings) .frame(height: 116) .accessibilityLabel("Preview Pad") @@ -1214,14 +1214,13 @@ private struct ActivityMetric: View { private struct ClickPreviewPad: NSViewRepresentable { let settings: ClickSettings - let activityStore: ClickActivityStore func makeNSView(context: Context) -> InteractiveClickPreviewView { - InteractiveClickPreviewView(settings: settings, activityStore: activityStore) + InteractiveClickPreviewView(settings: settings) } func updateNSView(_ nsView: InteractiveClickPreviewView, context: Context) { - nsView.apply(settings: settings, activityStore: activityStore) + nsView.apply(settings: settings) } } @@ -1229,11 +1228,9 @@ private struct ClickPreviewPad: NSViewRepresentable { private final class InteractiveClickPreviewView: NSView { private let overlayView: ClickOverlayView private var settings: ClickSettings - private var activityStore: ClickActivityStore - init(settings: ClickSettings, activityStore: ClickActivityStore) { + init(settings: ClickSettings) { self.settings = settings - self.activityStore = activityStore self.overlayView = ClickOverlayView( screenFrame: CGRect(x: 0, y: 0, width: 200, height: 116), settings: settings @@ -1264,9 +1261,8 @@ private final class InteractiveClickPreviewView: NSView { bounds.contains(point) ? self : nil } - func apply(settings: ClickSettings, activityStore: ClickActivityStore) { + func apply(settings: ClickSettings) { self.settings = settings - self.activityStore = activityStore overlayView.apply(settings: settings) } @@ -1317,7 +1313,8 @@ private final class InteractiveClickPreviewView: NSView { location: location, timestamp: CACurrentMediaTime() ) - activityStore.record(clickEvent) + // Preview clicks only render the overlay; they are not real user + // activity and must not be counted in the daily stats. overlayView.show(event: clickEvent, settings: settings) } } diff --git a/Sources/ClickLight/CoordinateMapper.swift b/Sources/ClickLight/CoordinateMapper.swift deleted file mode 100644 index 3201d1d..0000000 --- a/Sources/ClickLight/CoordinateMapper.swift +++ /dev/null @@ -1,21 +0,0 @@ -import AppKit - -enum CoordinateMapper { - static func appKitPoint(from quartzPoint: CGPoint) -> CGPoint { - let screens = NSScreen.screens - if screens.contains(where: { $0.frame.contains(quartzPoint) }) { - return quartzPoint - } - - if let flipped = pointFlippedFromMainDisplay(quartzPoint), screens.contains(where: { $0.frame.contains(flipped) }) { - return flipped - } - - return NSEvent.mouseLocation - } - - private static func pointFlippedFromMainDisplay(_ point: CGPoint) -> CGPoint? { - guard let mainScreen = NSScreen.main else { return nil } - return CGPoint(x: point.x, y: mainScreen.frame.maxY - point.y) - } -} diff --git a/Sources/ClickLight/StatusController.swift b/Sources/ClickLight/StatusController.swift index 459123d..29df26f 100644 --- a/Sources/ClickLight/StatusController.swift +++ b/Sources/ClickLight/StatusController.swift @@ -273,6 +273,9 @@ final class StatusController: NSObject { } button.imagePosition = titleParts.isEmpty ? .imageOnly : .imageLeading button.title = titleParts.joined(separator: " ") + // Subtle state cue: dim the status item while ClickLight is disabled + // so a global-hotkey toggle has visible feedback. + button.alphaValue = settings.isEnabled ? 1.0 : 0.45 } private func compactCount(_ value: Int) -> String {