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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name: CI
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]

Expand Down
142 changes: 113 additions & 29 deletions dhavnii/Features/Hotkeys/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<HotkeyManager>.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<HotkeyManager>.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)")
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func stopMonitoring() {
isHoldActive = false

if let handler = eventHandler {
RemoveEventHandler(handler)
eventHandler = nil
Expand Down
44 changes: 43 additions & 1 deletion dhavnii/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion openwispher/openwispherApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down