Skip to content
Closed
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
21 changes: 16 additions & 5 deletions Sources/ClickIt/Core/Click/ClickCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,21 +360,32 @@ 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
if configuration.useDynamicMouseTracking {
// Active target mode: get current mouse position
clickLocation = NSEvent.mouseLocation
print("ClickCoordinator: Active target mode - clicking at cursor position \(clickLocation)")
} else {
// Fixed location mode: use configured location
clickLocation = 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
if configuration.showVisualFeedback {
VisualFeedbackOverlay.shared.updateOverlay(at: configuration.location, isActive: true)
VisualFeedbackOverlay.shared.updateOverlay(at: clickLocation, isActive: true)
}

return result
}

Expand Down
71 changes: 66 additions & 5 deletions Sources/ClickIt/Core/Hotkeys/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,21 @@ 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 left mouse button is clicked (for active target mode)
var onLeftMouseClick: (() -> Void)?
private var lastMouseClickTime: TimeInterval = 0
private let mouseClickDebounceInterval: TimeInterval = 0.1 // 100ms debounce for mouse clicks

// MARK: - Initialization

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

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

func registerGlobalHotkey(_ config: HotkeyConfiguration) -> Bool {
Expand Down Expand Up @@ -70,15 +80,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 left mouse clicks globally (when app is in background)
globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
self?.handleMouseClick(event)
}

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

print("HotkeyManager: Successfully registered mouse click monitoring")
}

/// 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")
}

// MARK: - Private Methods

Expand All @@ -91,19 +135,36 @@ 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
print("HotkeyManager: Left mouse click detected for active target mode")

// Invoke callback on main thread
Task { @MainActor in
self.onLeftMouseClick?()
}
}

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()
}

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 +45 to +76
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

To improve maintainability and reduce code duplication, the common logic for resetting the cursor state in restoreNormalCursor() and forceRestoreNormalCursor() can be extracted into a private helper method. This makes the code cleaner and easier to manage.

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

            self.performCursorReset()
            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 else { return }

            self.performCursorReset()
            self.logger.info("Cursor forcefully restored")
        }
    }

    private func performCursorReset() {
        isTargetCursorActive = false
        cursorUpdateTimer?.invalidate()
        cursorUpdateTimer = nil
        NSCursor.arrow.set()
    }

}
Loading
Loading