Skip to content
Open
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
12 changes: 12 additions & 0 deletions Sources/ClickLight/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 5 additions & 0 deletions Sources/ClickLight/ClickCaptureController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

protocol ClickEventCapturing: AnyObject {
var statusLabel: String { get }
var usesEventTap: Bool { get }

func start(
laserPointerEnabled: Bool,
Expand All @@ -25,6 +26,10 @@ final class ClickCaptureController {
eventTap.statusLabel
}

var usesEventTap: Bool {
eventTap.usesEventTap
}

func startIfEnabled() {
guard settingsStore.settings.isEnabled else { return }
eventTap.start(
Expand Down
34 changes: 26 additions & 8 deletions Sources/ClickLight/ClickEventTap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ final class ClickEventTap: ClickEventCapturing {
}
}

var usesEventTap: Bool {
eventTap != nil
}

func start(
laserPointerEnabled: Bool,
liveKeyboardShortcutsEnabled: Bool,
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -151,36 +156,49 @@ 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)
}

guard let kind = ClickKind(type: type, event: event) else {
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(
Expand Down
17 changes: 7 additions & 10 deletions Sources/ClickLight/ClickLightSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -1214,26 +1214,23 @@ 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)
}
}

@MainActor
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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
Expand Down
21 changes: 0 additions & 21 deletions Sources/ClickLight/CoordinateMapper.swift

This file was deleted.

3 changes: 3 additions & 0 deletions Sources/ClickLight/StatusController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down