diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b9a663..ca4e202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: pull_request: branches: [main] + types: [opened, synchronize, reopened, ready_for_review] push: branches: [main] diff --git a/dhavnii/Features/Hotkeys/HotkeyManager.swift b/dhavnii/Features/Hotkeys/HotkeyManager.swift index ad45a23..e4222bf 100644 --- a/dhavnii/Features/Hotkeys/HotkeyManager.swift +++ b/dhavnii/Features/Hotkeys/HotkeyManager.swift @@ -126,6 +126,39 @@ internal struct HotkeyDefinition: Equatable { } } +internal enum HotkeyActivationMode: String, CaseIterable, Identifiable { + internal static let defaultsKey = "hotkeyActivationMode" + + case toggle + case hold + + internal var id: String { rawValue } + + internal var title: String { + switch self { + case .toggle: + return "Press to Start/Stop" + case .hold: + return "Hold to Record" + } + } + + internal static func loadFromDefaults() -> HotkeyActivationMode { + let defaults = UserDefaults.standard + guard + let rawValue = defaults.string(forKey: defaultsKey), + let mode = HotkeyActivationMode(rawValue: rawValue) + else { + return .toggle + } + return mode + } + + internal func saveToDefaults() { + UserDefaults.standard.set(rawValue, forKey: HotkeyActivationMode.defaultsKey) + } +} + /// Manages global hotkey registration using Carbon API for robustness /// /// Memory Safety: Uses Unmanaged.passRetained to ensure the instance stays alive @@ -137,18 +170,34 @@ class HotkeyManager { private var lastTriggerTime: Date = .distantPast private var selfPointer: UnsafeMutableRawPointer? private var hotkeyDefinition: HotkeyDefinition + private var activationMode: HotkeyActivationMode + private var isHoldActive = false - // Callback triggered when the hotkey is pressed - let onTrigger: () -> Void + let onToggle: () -> Void + let onHoldStart: () -> Void + let onHoldEnd: () -> Void - init(hotkey: HotkeyDefinition = .defaultHotkey, onTrigger: @escaping () -> Void) { + init( + hotkey: HotkeyDefinition = .defaultHotkey, + activationMode: HotkeyActivationMode = .loadFromDefaults(), + onToggle: @escaping () -> Void, + onHoldStart: @escaping () -> Void, + onHoldEnd: @escaping () -> Void + ) { self.hotkeyDefinition = hotkey - self.onTrigger = onTrigger + self.activationMode = activationMode + self.onToggle = onToggle + self.onHoldStart = onHoldStart + self.onHoldEnd = onHoldEnd } internal var currentHotkey: HotkeyDefinition { hotkeyDefinition } + + internal var currentActivationMode: HotkeyActivationMode { + activationMode + } /// Start monitoring for the configured hotkey @discardableResult @@ -176,37 +225,59 @@ class HotkeyManager { } // Install the event handler - var eventType = EventTypeSpec() - eventType.eventClass = OSType(kEventClassKeyboard) - eventType.eventKind = OSType(kEventHotKeyPressed) + var eventTypes = [EventTypeSpec(), EventTypeSpec()] + eventTypes[0].eventClass = OSType(kEventClassKeyboard) + eventTypes[0].eventKind = OSType(kEventHotKeyPressed) + eventTypes[1].eventClass = OSType(kEventClassKeyboard) + eventTypes[1].eventKind = OSType(kEventHotKeyReleased) + let eventTypeCount = eventTypes.count // Pass retained 'self' as user data to the callback // This ensures the instance stays alive while the handler is installed selfPointer = Unmanaged.passRetained(self).toOpaque() - let handlerStatus = InstallEventHandler( - GetApplicationEventTarget(), - { (nextHandler, event, userData) -> OSStatus in - // Recover the instance - take retained value to balance the passRetained - guard let userData = userData else { return noErr } - let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue() - - // Trigger the callback on main thread - DispatchQueue.main.async { - // Debounce: Ignore triggers within 0.5 seconds - if Date().timeIntervalSince(manager.lastTriggerTime) > 0.5 { - manager.lastTriggerTime = Date() - manager.onTrigger() + let handlerStatus = eventTypes.withUnsafeMutableBufferPointer { buffer -> OSStatus in + // Copy the baseAddress out to avoid overlapping access to `eventTypes` + let baseAddress = buffer.baseAddress + return InstallEventHandler( + GetApplicationEventTarget(), + { (nextHandler, event, userData) -> OSStatus in + // Recover the instance - take retained value to balance the passRetained + guard let userData = userData else { return noErr } + let manager = Unmanaged.fromOpaque(userData).takeUnretainedValue() + + guard let event else { return noErr } + let eventKind = GetEventKind(event) + + DispatchQueue.main.async { + switch manager.activationMode { + case .toggle: + guard eventKind == UInt32(kEventHotKeyPressed) else { return } + if Date().timeIntervalSince(manager.lastTriggerTime) > 0.5 { + manager.lastTriggerTime = Date() + manager.onToggle() + } + case .hold: + if eventKind == UInt32(kEventHotKeyPressed) { + guard !manager.isHoldActive else { return } + manager.isHoldActive = true + manager.onHoldStart() + } else if eventKind == UInt32(kEventHotKeyReleased) { + guard manager.isHoldActive else { return } + manager.isHoldActive = false + manager.onHoldEnd() + } + } } - } - - return noErr - }, - 1, - &eventType, - selfPointer, - &eventHandler - ) + + return noErr + }, + eventTypeCount, + baseAddress, + selfPointer, + &eventHandler + ) + } if handlerStatus != noErr { print("Failed to install hotkey handler: \(handlerStatus)") @@ -237,8 +308,21 @@ class HotkeyManager { _ = startMonitoring() return false } + + internal func updateActivationMode(_ newMode: HotkeyActivationMode) { + if newMode != .hold && (activationMode == .hold || isHoldActive) { + if isHoldActive { + onHoldEnd() + } + isHoldActive = false + } + + activationMode = newMode + } func stopMonitoring() { + isHoldActive = false + if let handler = eventHandler { RemoveEventHandler(handler) eventHandler = nil diff --git a/dhavnii/Features/Settings/SettingsView.swift b/dhavnii/Features/Settings/SettingsView.swift index 6536c0e..06f2897 100644 --- a/dhavnii/Features/Settings/SettingsView.swift +++ b/dhavnii/Features/Settings/SettingsView.swift @@ -931,6 +931,7 @@ private struct GeneralSettingsView: View { @AppStorage("autoLaunchEnabled") private var autoLaunchEnabled = false @State private var isRecordingHotkey = false @State private var hotkey = HotkeyDefinition.loadFromDefaults() + @State private var activationMode = HotkeyActivationMode.loadFromDefaults() @State private var isApplyingHotkey = false var body: some View { @@ -955,13 +956,42 @@ private struct GeneralSettingsView: View { SettingsGroup(title: "Hotkey") { SettingsRow( icon: "keyboard", title: "Global Shortcut", - subtitle: "Start and stop recording" + subtitle: "Choose shortcut and trigger behavior" ) { HotkeyRecorderControl( hotkey: $hotkey, isRecording: $isRecordingHotkey ) } + + SettingsRow( + icon: "hand.tap", title: "Activation", + subtitle: "Press toggle or hold to record" + ) { + Menu { + ForEach(HotkeyActivationMode.allCases) { mode in + Button(mode.title) { + activationMode = mode + } + } + } label: { + HStack(spacing: 6) { + Text(activationMode.title) + .font(.system(size: 13, weight: .medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + } + .menuStyle(.borderlessButton) + } } // Status @@ -1012,10 +1042,14 @@ private struct GeneralSettingsView: View { } .onAppear { hotkey = HotkeyDefinition.loadFromDefaults() + activationMode = HotkeyActivationMode.loadFromDefaults() } .onChange(of: hotkey) { _, newValue in applyHotkeyChange(newValue) } + .onChange(of: activationMode) { _, newValue in + applyActivationModeChange(newValue) + } } private var statusColor: Color { @@ -1048,6 +1082,7 @@ private struct GeneralSettingsView: View { } UserDefaults.standard.removeObject(forKey: HotkeyDefinition.keyCodeDefaultsKey) UserDefaults.standard.removeObject(forKey: HotkeyDefinition.modifiersDefaultsKey) + UserDefaults.standard.removeObject(forKey: HotkeyActivationMode.defaultsKey) UserDefaults.standard.synchronize() SecureStorage.clearAllAPIKeys() @@ -1081,6 +1116,13 @@ private struct GeneralSettingsView: View { fallback.saveToDefaults() } } + + private func applyActivationModeChange(_ newMode: HotkeyActivationMode) { + if let hotkeyManager { + hotkeyManager.updateActivationMode(newMode) + } + newMode.saveToDefaults() + } } // MARK: - History Settings diff --git a/openwispher/openwispherApp.swift b/openwispher/openwispherApp.swift index 889e24e..bbffe86 100644 --- a/openwispher/openwispherApp.swift +++ b/openwispher/openwispherApp.swift @@ -194,7 +194,8 @@ private struct AppContentView: View { let savedHotkey = HotkeyDefinition.loadFromDefaults() let hotkey = HotkeyManager( hotkey: savedHotkey, - onTrigger: { [weak service, weak appState] in + activationMode: HotkeyActivationMode.loadFromDefaults(), + onToggle: { [weak service, weak appState] in guard let service = service, let appState = appState else { print("❌ Hotkey triggered but service/appState is nil") return @@ -214,6 +215,25 @@ private struct AppContentView: View { default: print("⚠️ Cannot toggle - state is \(appState.recordingState)") } + }, + onHoldStart: { [weak service, weak appState] in + guard let service = service, let appState = appState else { return } + guard appState.recordingState == .idle else { + print("⚠️ Cannot start hold recording - state is \(appState.recordingState)") + return + } + print("▶️ Hold started. Starting recording...") + AnalyticsManager.shared.trackHotkeyPressed(hotkey: savedHotkey) + service.startRecording() + }, + onHoldEnd: { [weak service, weak appState] in + guard let service = service, let appState = appState else { return } + guard appState.recordingState == .recording else { + print("⚠️ Hold ended but recording is not active") + return + } + print("⏹️ Hold released. Stopping recording...") + service.stopRecording() }) hotkey.startMonitoring() hotkeyManager = hotkey