From 4706afe7d960abed85a9bf7dd5d0d83379c86624 Mon Sep 17 00:00:00 2001 From: Ruczhutao Date: Fri, 17 Jul 2026 13:17:30 +0800 Subject: [PATCH 1/4] Retry event tap creation after Accessibility permission is granted Without Accessibility permission, the initial CGEvent.tapCreate call at launch returns nil and capture silently falls back to the NSEvent monitor. Granting permission afterwards never retried tap creation, so click capture stayed degraded until a full restart. When the app becomes active again, if permission is now trusted and the capture pipeline is not running in event-tap mode, refresh the capture state. ClickEventTap.start already recreates the tap when it is missing, so this is a minimal retry hook plus a usesEventTap readout on the capture pipeline. --- Sources/ClickLight/AppDelegate.swift | 12 ++++++++++++ Sources/ClickLight/ClickCaptureController.swift | 5 +++++ Sources/ClickLight/ClickEventTap.swift | 4 ++++ 3 files changed, 21 insertions(+) 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..13d59df 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, From 1462719dfa19ac264e4477208746b00061d4e42d Mon Sep 17 00:00:00 2001 From: Ruczhutao Date: Fri, 17 Jul 2026 13:18:30 +0800 Subject: [PATCH 2/4] Sample click coordinates at event time instead of delivery time Both capture channels read NSEvent.mouseLocation inside a DispatchQueue.main.async block, so coordinates reflected wherever the pointer happened to be at delivery rather than at the click. Pulses drifted when the mouse moved right after clicking, and because the event tap and the fallback monitor sampled at different moments, the same physical click produced two different locations and slipped past the 3px dedup in OverlayCoordinator, showing double pulses. Sample the position synchronously in each callback and pass it along with the event: CGEvent.location converted from Quartz to AppKit coordinates in the tap, NSEvent.mouseLocation in the monitor. Both channels now report identical coordinates for the same click. Also remove CoordinateMapper, which was dead code. Its heuristic of treating any point inside a screen frame as already AppKit-flipped is unsound because Quartz and AppKit coordinates overlap on the main display, and nothing called it. --- Sources/ClickLight/ClickEventTap.swift | 30 +++++++++++++++++------ Sources/ClickLight/CoordinateMapper.swift | 21 ---------------- 2 files changed, 22 insertions(+), 29 deletions(-) delete mode 100644 Sources/ClickLight/CoordinateMapper.swift diff --git a/Sources/ClickLight/ClickEventTap.swift b/Sources/ClickLight/ClickEventTap.swift index 13d59df..4d28b91 100644 --- a/Sources/ClickLight/ClickEventTap.swift +++ b/Sources/ClickLight/ClickEventTap.swift @@ -130,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) } } @@ -155,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) } @@ -163,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/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) - } -} From 0a9fb0562c394b58066c9acc8b2068435ddeca1d Mon Sep 17 00:00:00 2001 From: Ruczhutao Date: Fri, 17 Jul 2026 13:19:16 +0800 Subject: [PATCH 3/4] Stop counting Preview Pad clicks in click activity stats AppDelegate deliberately excludes real clicks that land on the Settings window from ClickActivityStore, but the Preview Pad inside that same window recorded every preview click into the store, inflating the daily totals and the menu bar counter with clicks that never happened outside the app. Preview interactions now only render the overlay, consistent with the existing settings-window exclusion, and the preview view no longer carries an activity store reference. --- Sources/ClickLight/ClickLightSettingsView.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) 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) } } From 9a7e54370c866cafa744d2f756738ab73bf903fa Mon Sep 17 00:00:00 2001 From: Ruczhutao Date: Fri, 17 Jul 2026 13:19:49 +0800 Subject: [PATCH 4/4] Dim the menu bar icon while ClickLight is disabled Toggling the enabled state via the global hotkey gave no feedback, so during a demo there was no way to confirm whether click highlights were actually on without opening the menu. applyStatusItemAppearance already runs on every settings change, which includes hotkey toggles, so it now also dims the status item button while the app is disabled. --- Sources/ClickLight/StatusController.swift | 3 +++ 1 file changed, 3 insertions(+) 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 {