diff --git a/ClickIt/Info.plist b/ClickIt/Info.plist index e6aeca3..55c61ac 100644 --- a/ClickIt/Info.plist +++ b/ClickIt/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5.3 + 1.5.5 CFBundleVersion $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion diff --git a/Sources/ClickIt/Core/Click/ClickCoordinator.swift b/Sources/ClickIt/Core/Click/ClickCoordinator.swift index 1957426..1626258 100644 --- a/Sources/ClickIt/Core/Click/ClickCoordinator.swift +++ b/Sources/ClickIt/Core/Click/ClickCoordinator.swift @@ -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 } diff --git a/Sources/ClickIt/Core/Hotkeys/HotkeyManager.swift b/Sources/ClickIt/Core/Hotkeys/HotkeyManager.swift index 487e118..f27c035 100644 --- a/Sources/ClickIt/Core/Hotkeys/HotkeyManager.swift +++ b/Sources/ClickIt/Core/Hotkeys/HotkeyManager.swift @@ -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 @@ -36,6 +45,7 @@ class HotkeyManager: ObservableObject { func cleanup() { unregisterGlobalHotkey() + unregisterMouseMonitor() } func registerGlobalHotkey(_ config: HotkeyConfiguration) -> Bool { @@ -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 @@ -91,12 +135,12 @@ 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)") @@ -104,6 +148,23 @@ class HotkeyManager: ObservableObject { 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 diff --git a/Sources/ClickIt/Core/Models/ClickSettings.swift b/Sources/ClickIt/Core/Models/ClickSettings.swift index b6052a4..139ebe3 100644 --- a/Sources/ClickIt/Core/Models/ClickSettings.swift +++ b/Sources/ClickIt/Core/Models/ClickSettings.swift @@ -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 @@ -231,6 +238,7 @@ class ClickSettings: ObservableObject { stopOnError: stopOnError, showVisualFeedback: showVisualFeedback, playSoundFeedback: playSoundFeedback, + isActiveTargetMode: isActiveTargetMode, randomizeTiming: randomizeTiming, timingVariancePercentage: timingVariancePercentage, distributionPattern: distributionPattern, @@ -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 @@ -293,6 +302,7 @@ class ClickSettings: ObservableObject { stopOnError = false showVisualFeedback = true playSoundFeedback = false + isActiveTargetMode = false randomizeTiming = false timingVariancePercentage = 0.1 distributionPattern = .normal @@ -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, @@ -331,6 +341,8 @@ class ClickSettings: ObservableObject { stopOnError: stopOnError, randomizeLocation: randomizeLocation, locationVariance: CGFloat(locationVariance), + useDynamicMouseTracking: isActiveTargetMode, + showVisualFeedback: showVisualFeedback, cpsRandomizerConfig: createCPSRandomizerConfiguration() ) } @@ -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 ) @@ -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 @@ -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 @@ -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 diff --git a/Sources/ClickIt/Services/CursorManager.swift b/Sources/ClickIt/Services/CursorManager.swift new file mode 100644 index 0000000..d98b614 --- /dev/null +++ b/Sources/ClickIt/Services/CursorManager.swift @@ -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") + } + } +} diff --git a/Sources/ClickIt/UI/ViewModels/ClickItViewModel.swift b/Sources/ClickIt/UI/ViewModels/ClickItViewModel.swift index 035e845..cebb151 100644 --- a/Sources/ClickIt/UI/ViewModels/ClickItViewModel.swift +++ b/Sources/ClickIt/UI/ViewModels/ClickItViewModel.swift @@ -21,7 +21,12 @@ class ClickItViewModel: ObservableObject { // Settings @Published var clickSettings = ClickSettings() - + + // Expose settings for UI binding + var settings: ClickSettings { + clickSettings + } + // Configuration Properties @Published var intervalHours = 0 { didSet { @@ -112,6 +117,9 @@ class ClickItViewModel: ObservableObject { // MARK: - Dependencies private let clickCoordinator = ClickCoordinator.shared private let schedulingManager = SchedulingManager.shared + + // MARK: - Active Target Mode State + private var isProcessingActiveTargetClick = false // MARK: - Initialization init() { @@ -453,11 +461,11 @@ class ClickItViewModel: ObservableObject { self?.updateStatistics() } .store(in: &cancellables) - + // Monitor automation active state to sync UI state clickCoordinator.$isActive.sink { [weak self] isActive in guard let self = self else { return } - + // Sync ViewModel state with ClickCoordinator state if !isActive && (self.isRunning || self.isPaused) { print("ClickItViewModel: Automation stopped externally (e.g., DELETE key), updating UI state") @@ -469,13 +477,100 @@ class ClickItViewModel: ObservableObject { } } .store(in: &cancellables) + + // Monitor active target mode changes + clickSettings.$isActiveTargetMode.sink { [weak self] isEnabled in + guard let self = self else { return } + self.handleActiveTargetModeChange(isEnabled) + } + .store(in: &cancellables) } private func updateStatistics() { // SIMPLE WORKING APPROACH: Use ClickCoordinator statistics directly statistics = clickCoordinator.getSessionStatistics() } - + + // MARK: - Active Target Mode Management + + private func handleActiveTargetModeChange(_ isEnabled: Bool) { + print("ClickItViewModel: Active target mode changed to \(isEnabled)") + + if isEnabled { + // Enable active target mode + CursorManager.shared.showTargetCursor() + setupMouseClickHandler() + } else { + // Disable active target mode + CursorManager.shared.restoreNormalCursor() + removeMouseClickHandler() + } + } + + private func setupMouseClickHandler() { + // Set up the click handler for active target mode + HotkeyManager.shared.onLeftMouseClick = { [weak self] in + Task { @MainActor in + self?.handleActiveTargetClick() + } + } + + // Register mouse monitoring + HotkeyManager.shared.registerMouseMonitor() + print("ClickItViewModel: Mouse click handler registered for active target mode") + } + + private func removeMouseClickHandler() { + // Remove the click handler + HotkeyManager.shared.onLeftMouseClick = nil + + // Unregister mouse monitoring + HotkeyManager.shared.unregisterMouseMonitor() + print("ClickItViewModel: Mouse click handler removed") + } + + private func handleActiveTargetClick() { + // Prevent re-entrancy from rapid clicks or automated clicks + guard !isProcessingActiveTargetClick else { + print("ClickItViewModel: Ignoring click - already processing") + return + } + + isProcessingActiveTargetClick = true + defer { + // Reset the flag after a short delay to allow next click + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.isProcessingActiveTargetClick = false + } + } + + print("ClickItViewModel: Active target click detected, isRunning: \(isRunning)") + + // Toggle automation on/off with left click + if isRunning { + // Stop automation + stopAutomation() + print("ClickItViewModel: Stopped automation via active target click") + } else { + // Start automation - but only if other prerequisites are met + if canStartAutomation || clickSettings.isActiveTargetMode { + // In active target mode, capture the current mouse position + // This is just for validation - the actual clicking will use live position + if clickSettings.isActiveTargetMode { + let currentMousePosition = NSEvent.mouseLocation + targetPoint = currentMousePosition + clickSettings.clickLocation = currentMousePosition + print("ClickItViewModel: Captured mouse position for active target mode: \(currentMousePosition)") + } + + startAutomation() + print("ClickItViewModel: Started automation via active target click") + } else { + print("ClickItViewModel: Cannot start automation - prerequisites not met") + } + } + } + // MARK: - Emergency Stop /// Performs emergency stop using ClickCoordinator directly diff --git a/Sources/ClickIt/UI/Views/QuickStartTab.swift b/Sources/ClickIt/UI/Views/QuickStartTab.swift index 6bd1285..3380570 100644 --- a/Sources/ClickIt/UI/Views/QuickStartTab.swift +++ b/Sources/ClickIt/UI/Views/QuickStartTab.swift @@ -31,6 +31,9 @@ struct QuickStartTab: View { // Target Point Selector (with Timer Support) TargetPointSelectionCard(viewModel: viewModel) + // Active Target Mode Toggle + ActiveTargetModeCard() + // Inline Timing Controls InlineTimingControls() @@ -258,19 +261,19 @@ private struct CompactStatisticView: View { let title: String let value: String let icon: String - + var body: some View { VStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 14)) .foregroundColor(.blue) - + Text(value) .font(.system(.caption, design: .monospaced)) .fontWeight(.semibold) .lineLimit(1) .minimumScaleFactor(0.8) - + Text(title) .font(.system(size: 9)) .foregroundColor(.secondary) @@ -282,6 +285,47 @@ private struct CompactStatisticView: View { } } +// Active Target Mode Card +private struct ActiveTargetModeCard: View { + @EnvironmentObject private var viewModel: ClickItViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "scope") + .foregroundColor(.purple) + .font(.system(size: 14)) + + Toggle("Active Target Mode", isOn: $viewModel.clickSettings.isActiveTargetMode) + .font(.subheadline) + .fontWeight(.medium) + .toggleStyle(.switch) + } + + if viewModel.clickSettings.isActiveTargetMode { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .foregroundColor(.purple) + .font(.system(size: 10)) + + Text("Cursor becomes crosshair. Left-click to start/stop clicking at cursor position.") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Spacer() + } + .padding(.top, 4) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(NSColor.textBackgroundColor)) + ) + } +} + // MARK: - Preview struct QuickStartTab_Previews: PreviewProvider {