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
2 changes: 1 addition & 1 deletion ClickIt/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.5.3</string>
<string>1.5.5</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
Expand Down
10 changes: 10 additions & 0 deletions Sources/ClickIt/ClickItApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ struct ClickItApp: App {
}
}
}

// Separate window for click test - can be moved independently
WindowGroup(id: "click-test-window") {
ClickTestWindow()
}
.windowResizability(.contentSize)
.defaultSize(width: 900, height: 750)
.windowToolbarStyle(.unified)
}

// MARK: - Safe Initialization
Expand Down Expand Up @@ -76,6 +84,8 @@ struct ClickItApp: App {
// Cleanup visual feedback overlay when app terminates
VisualFeedbackOverlay.shared.cleanup()
HotkeyManager.shared.cleanup()
// Restore cursor to normal
CursorManager.shared.forceRestoreNormalCursor()
}

print("ClickItApp: Safe app initialization completed")
Expand Down
28 changes: 22 additions & 6 deletions Sources/ClickIt/Core/Click/ClickCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,21 +360,37 @@ class ClickCoordinator: ObservableObject {
/// Simple automation step execution from working version
private func executeAutomationStep(configuration: AutomationConfiguration) async -> ClickResult {
print("ClickCoordinator: executeAutomationStep() - Simple working approach")


// Determine click location based on mode
let clickLocation: CGPoint // CoreGraphics coordinates for clicking
let visualFeedbackLocation: CGPoint // AppKit coordinates for visual feedback

if configuration.useDynamicMouseTracking {
// Active target mode: get current mouse position
let appKitLocation = NSEvent.mouseLocation
clickLocation = convertAppKitToCoreGraphicsMultiMonitor(appKitLocation)
visualFeedbackLocation = appKitLocation
print("ClickCoordinator: Active target mode - AppKit: \(appKitLocation) → CoreGraphics: \(clickLocation)")
} else {
// Fixed location mode: use configured location (already in CoreGraphics)
clickLocation = configuration.location
visualFeedbackLocation = convertCoreGraphicsToAppKitMultiMonitor(configuration.location)
}

// Use the working performSingleClick method
let result = await performSingleClick(
configuration: ClickConfiguration(
type: configuration.clickType,
location: configuration.location,
location: clickLocation,
targetPID: nil
)
)
// Update visual feedback if enabled

// Update visual feedback if enabled (requires AppKit coordinates)
if configuration.showVisualFeedback {
VisualFeedbackOverlay.shared.updateOverlay(at: configuration.location, isActive: true)
VisualFeedbackOverlay.shared.updateOverlay(at: visualFeedbackLocation, isActive: true)
}

return result
}

Expand Down
74 changes: 69 additions & 5 deletions Sources/ClickIt/Core/Hotkeys/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,22 @@ class HotkeyManager: ObservableObject {
@Published var emergencyStopActivated: Bool = false

// MARK: - Private Properties

private var globalEventMonitor: Any?
private var localEventMonitor: Any?
private var globalMouseMonitor: Any?
private var localMouseMonitor: Any?
private var lastHotkeyTime: TimeInterval = 0
private let hotkeyDebounceInterval: TimeInterval = 0.01 // 10ms debounce for ultra-fast emergency response
private var responseTimeTracker: EmergencyStopResponseTracker?

// MARK: - Mouse Click Handling

/// Callback invoked when right mouse button is clicked (for active target mode - TOGGLE)
var onRightMouseClick: (() -> Void)?

private var lastMouseClickTime: TimeInterval = 0
private let mouseClickDebounceInterval: TimeInterval = 0.1 // 100ms debounce for mouse clicks

// MARK: - Initialization

Expand All @@ -36,6 +46,7 @@ class HotkeyManager: ObservableObject {

func cleanup() {
unregisterGlobalHotkey()
unregisterMouseMonitor()
}

func registerGlobalHotkey(_ config: HotkeyConfiguration) -> Bool {
Expand Down Expand Up @@ -70,15 +81,49 @@ class HotkeyManager: ObservableObject {
NSEvent.removeMonitor(monitor)
globalEventMonitor = nil
}

if let monitor = localEventMonitor {
NSEvent.removeMonitor(monitor)
localEventMonitor = nil
}

isRegistered = false
print("HotkeyManager: Successfully unregistered hotkey monitoring")
}

/// Register global mouse click monitoring for active target mode
func registerMouseMonitor() {
// Unregister existing monitors first
unregisterMouseMonitor()

// Monitor right mouse clicks globally (when app is in background)
globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in
self?.handleMouseClick(event)
}

// Monitor right mouse clicks locally (when app is in foreground)
localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in
self?.handleMouseClick(event)
return event // Pass through the event
}

print("HotkeyManager: Successfully registered right-click monitoring (toggle start/stop)")
}

/// Unregister mouse click monitoring
func unregisterMouseMonitor() {
if let monitor = globalMouseMonitor {
NSEvent.removeMonitor(monitor)
globalMouseMonitor = nil
}

if let monitor = localMouseMonitor {
NSEvent.removeMonitor(monitor)
localMouseMonitor = nil
}

print("HotkeyManager: Successfully unregistered mouse click monitoring")
}
Comment on lines +114 to +126
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The implementation of unregisterMouseMonitor() is very similar to the existing unregisterGlobalHotkey() method. To reduce code duplication and improve maintainability, you could extract this common logic into a private helper function.

For example:

private func removeMonitors(global: inout Any?, local: inout Any?) {
    if let monitor = global {
        NSEvent.removeMonitor(monitor)
        global = nil
    }
    if let monitor = local {
        NSEvent.removeMonitor(monitor)
        local = nil
    }
}

func unregisterMouseMonitor() {
    removeMonitors(global: &globalMouseMonitor, local: &localMouseMonitor)
    print("HotkeyManager: Successfully unregistered mouse click monitoring")
}

You could then refactor unregisterGlobalHotkey() to use this helper as well.


// MARK: - Private Methods

Expand All @@ -91,19 +136,38 @@ class HotkeyManager: ObservableObject {

private func handleMultiKeyEvent(_ event: NSEvent) {
let currentTime = CFAbsoluteTimeGetCurrent()

// Debounce emergency stop to prevent rapid fire (50ms for emergency response)
if currentTime - lastHotkeyTime < hotkeyDebounceInterval {
return
}

// Check if this event matches any emergency stop key
if let matchedConfig = matchEmergencyStopKey(event) {
print("HotkeyManager: Emergency stop key activated - \(matchedConfig.description)")
lastHotkeyTime = currentTime
handleEmergencyStop(triggeredBy: matchedConfig)
}
}

private func handleMouseClick(_ event: NSEvent) {
let currentTime = CFAbsoluteTimeGetCurrent()

// Debounce mouse clicks to prevent accidental double-clicks
if currentTime - lastMouseClickTime < mouseClickDebounceInterval {
return
}

lastMouseClickTime = currentTime

// Handle right-click for toggle (start/stop)
if event.type == .rightMouseDown {
print("HotkeyManager: Right-click detected for active target mode (TOGGLE)")
Task { @MainActor in
self.onRightMouseClick?()
}
}
}

private func matchEmergencyStopKey(_ event: NSEvent) -> HotkeyConfiguration? {
// Check all available emergency stop configurations
Expand Down
24 changes: 20 additions & 4 deletions Sources/ClickIt/Core/Models/ClickSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,14 @@ class ClickSettings: ObservableObject {
saveSettings()
}
}


/// Active target mode - clicks follow cursor position
@Published var isActiveTargetMode: Bool = false {
didSet {
saveSettings()
}
}

// MARK: - CPS Randomization Properties

/// Whether to enable CPS timing randomization
Expand Down Expand Up @@ -231,6 +238,7 @@ class ClickSettings: ObservableObject {
stopOnError: stopOnError,
showVisualFeedback: showVisualFeedback,
playSoundFeedback: playSoundFeedback,
isActiveTargetMode: isActiveTargetMode,
randomizeTiming: randomizeTiming,
timingVariancePercentage: timingVariancePercentage,
distributionPattern: distributionPattern,
Expand Down Expand Up @@ -268,6 +276,7 @@ class ClickSettings: ObservableObject {
stopOnError = settings.stopOnError
showVisualFeedback = settings.showVisualFeedback
playSoundFeedback = settings.playSoundFeedback
isActiveTargetMode = settings.isActiveTargetMode ?? false // Default for backward compatibility
randomizeTiming = settings.randomizeTiming
timingVariancePercentage = settings.timingVariancePercentage
distributionPattern = settings.distributionPattern
Expand All @@ -293,6 +302,7 @@ class ClickSettings: ObservableObject {
stopOnError = false
showVisualFeedback = true
playSoundFeedback = false
isActiveTargetMode = false
randomizeTiming = false
timingVariancePercentage = 0.1
distributionPattern = .normal
Expand All @@ -318,8 +328,8 @@ class ClickSettings: ObservableObject {
func createAutomationConfiguration() -> AutomationConfiguration {
let maxClicksValue = durationMode == .clickCount ? maxClicks : nil
let maxDurationValue = durationMode == .timeLimit ? durationSeconds : nil
print("ClickSettings: Creating automation config with location \(clickLocation), showVisualFeedback: \(showVisualFeedback)")

print("ClickSettings: Creating automation config with location \(clickLocation), showVisualFeedback: \(showVisualFeedback), activeTargetMode: \(isActiveTargetMode)")

return AutomationConfiguration(
location: clickLocation,
Expand All @@ -331,6 +341,8 @@ class ClickSettings: ObservableObject {
stopOnError: stopOnError,
randomizeLocation: randomizeLocation,
locationVariance: CGFloat(locationVariance),
useDynamicMouseTracking: isActiveTargetMode,
showVisualFeedback: showVisualFeedback,
cpsRandomizerConfig: createCPSRandomizerConfiguration()
)
}
Expand All @@ -353,13 +365,14 @@ class ClickSettings: ObservableObject {
stopOnError: stopOnError,
showVisualFeedback: showVisualFeedback,
playSoundFeedback: playSoundFeedback,
isActiveTargetMode: isActiveTargetMode,
randomizeTiming: randomizeTiming,
timingVariancePercentage: timingVariancePercentage,
distributionPattern: distributionPattern,
humannessLevel: humannessLevel,
schedulingMode: schedulingMode,
scheduledDateTime: scheduledDateTime,
exportVersion: "1.1",
exportVersion: "1.2",
exportDate: Date(),
appVersion: AppConstants.appVersion
)
Expand Down Expand Up @@ -403,6 +416,7 @@ class ClickSettings: ObservableObject {
stopOnError = importData.stopOnError
showVisualFeedback = importData.showVisualFeedback
playSoundFeedback = importData.playSoundFeedback
isActiveTargetMode = importData.isActiveTargetMode ?? false // Default for backward compatibility
randomizeTiming = importData.randomizeTiming
timingVariancePercentage = importData.timingVariancePercentage
distributionPattern = importData.distributionPattern
Expand Down Expand Up @@ -527,6 +541,7 @@ private struct SettingsData: Codable {
let stopOnError: Bool
let showVisualFeedback: Bool
let playSoundFeedback: Bool
let isActiveTargetMode: Bool? // Optional for backward compatibility
let randomizeTiming: Bool
let timingVariancePercentage: Double
let distributionPattern: CPSRandomizer.DistributionPattern
Expand All @@ -550,6 +565,7 @@ struct SettingsExportData: Codable {
let stopOnError: Bool
let showVisualFeedback: Bool
let playSoundFeedback: Bool
let isActiveTargetMode: Bool? // Optional for backward compatibility
let randomizeTiming: Bool
let timingVariancePercentage: Double
let distributionPattern: CPSRandomizer.DistributionPattern
Expand Down
77 changes: 77 additions & 0 deletions Sources/ClickIt/Services/CursorManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// CursorManager.swift
// ClickIt
//
// Manages custom cursor states for active target mode
//

import Cocoa
import os.log

/// Manages cursor appearance for different automation modes
final class CursorManager {
static let shared = CursorManager()

private let logger = Logger(subsystem: "com.clickit.app", category: "CursorManager")
private var isTargetCursorActive = false
private var cursorUpdateTimer: Timer?

private init() {}

/// Shows the target/crosshair cursor for active target mode
func showTargetCursor() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

if !self.isTargetCursorActive {
self.isTargetCursorActive = true

// Set the cursor immediately
NSCursor.crosshair.set()

// Keep re-setting the cursor on a timer to ensure it stays active
// This is needed because system can reset cursor on window focus changes
self.cursorUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
guard let self = self, self.isTargetCursorActive else { return }
NSCursor.crosshair.set()
}
Comment on lines +34 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The timer closure should invalidate the timer itself when it's no longer needed. This prevents the timer from continuing to fire unnecessarily if isTargetCursorActive becomes false, making the implementation more robust and efficient.

                self.cursorUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
                    guard let self = self, self.isTargetCursorActive else {
                        timer.invalidate()
                        return
                    }
                    NSCursor.crosshair.set()
                }


self.logger.info("Target cursor activated with timer refresh")
}
}
}

/// Restores the normal system cursor
func restoreNormalCursor() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

if self.isTargetCursorActive {
self.isTargetCursorActive = false

// Stop the cursor update timer
self.cursorUpdateTimer?.invalidate()
self.cursorUpdateTimer = nil

// Restore arrow cursor
NSCursor.arrow.set()

self.logger.info("Normal cursor restored")
}
}
}

/// Force restore cursor (useful for cleanup on app termination)
func forceRestoreNormalCursor() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }

self.isTargetCursorActive = false
self.cursorUpdateTimer?.invalidate()
self.cursorUpdateTimer = nil
NSCursor.arrow.set()

self.logger.info("Cursor forcefully restored")
}
}
}
Comment on lines +44 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The functions restoreNormalCursor and forceRestoreNormalCursor contain a significant amount of duplicated code. You can refactor them to reduce this duplication, which will improve maintainability. forceRestoreNormalCursor can be implemented as a call to a more generic restoreNormalCursor function.

    /// Restores the normal system cursor
    func restoreNormalCursor(force: Bool = false) {
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }

            if force || self.isTargetCursorActive {
                self.isTargetCursorActive = false

                // Stop the cursor update timer
                self.cursorUpdateTimer?.invalidate()
                self.cursorUpdateTimer = nil

                // Restore arrow cursor
                NSCursor.arrow.set()

                self.logger.info(force ? "Cursor forcefully restored" : "Normal cursor restored")
            }
        }
    }

    /// Force restore cursor (useful for cleanup on app termination)
    func forceRestoreNormalCursor() {
        restoreNormalCursor(force: true)
    }
}

Loading
Loading