//go:build darwin
|
|
package hotkey
|
|
/*
|
#cgo CFLAGS: -x objective-c
|
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices
|
#import <Cocoa/Cocoa.h>
|
#include <ApplicationServices/ApplicationServices.h>
|
#include <pthread.h>
|
static volatile int g_keysDown[128];
|
static volatile double g_lastDownAt[128];
|
static volatile int g_monitorStarted = 0;
|
static volatile int g_monitorLaunching = 0;
|
static CFMachPortRef g_eventTap = NULL;
|
static CFRunLoopSourceRef g_eventTapSource = NULL;
|
static volatile unsigned long long g_monitorStartAttempts = 0;
|
static volatile unsigned long long g_eventTapCreateFailures = 0;
|
static volatile unsigned long long g_eventTapDisabledCount = 0;
|
static volatile unsigned long long g_altEventSeq[2];
|
static volatile int g_altEventDown[2];
|
static volatile int g_altEventType[2];
|
static volatile unsigned long long g_altEventFlags[2];
|
static volatile double g_altEventAt[2];
|
static id g_activityToken = nil;
|
|
static double nowSeconds(void) {
|
return CFAbsoluteTimeGetCurrent();
|
}
|
|
static void handleKeyEvent(unsigned short keyCode, BOOL isDown) {
|
if (keyCode >= 128) return;
|
g_keysDown[keyCode] = isDown ? 1 : 0;
|
if (isDown) g_lastDownAt[keyCode] = nowSeconds();
|
}
|
|
static int altIndexForKey(unsigned short keyCode) {
|
if (keyCode == 58) return 0; // L-Option
|
if (keyCode == 61) return 1; // R-Option
|
return -1;
|
}
|
|
static void recordAltEvent(unsigned short keyCode, int eventType, CGEventFlags flags) {
|
int index = altIndexForKey(keyCode);
|
if (index < 0) return;
|
g_altEventSeq[index]++;
|
g_altEventDown[index] = g_keysDown[keyCode] ? 1 : 0;
|
g_altEventType[index] = eventType;
|
g_altEventFlags[index] = flags;
|
g_altEventAt[index] = nowSeconds();
|
}
|
|
static unsigned long long modifierMaskForKey(unsigned short keyCode) {
|
switch (keyCode) {
|
case 59: // L-Ctrl
|
case 62: // R-Ctrl
|
return kCGEventFlagMaskControl;
|
case 58: // L-Option
|
case 61: // R-Option
|
return kCGEventFlagMaskAlternate;
|
case 56: // L-Shift
|
case 60: // R-Shift
|
return kCGEventFlagMaskShift;
|
case 55: // L-Command
|
case 54: // R-Command
|
return kCGEventFlagMaskCommand;
|
default:
|
return 0;
|
}
|
}
|
|
static void handleModifierEvent(unsigned short keyCode, CGEventFlags flags) {
|
if (keyCode >= 128) return;
|
|
unsigned long long mask = modifierMaskForKey(keyCode);
|
if (mask == 0) return;
|
|
BOOL physicalDown = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode);
|
handleKeyEvent(keyCode, physicalDown ? YES : NO);
|
recordAltEvent(keyCode, 3, flags);
|
}
|
|
static CGEventRef keyboardEventTapCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
|
if (type == kCGEventTapDisabledByTimeout || type == kCGEventTapDisabledByUserInput) {
|
g_eventTapDisabledCount++;
|
if (g_eventTap != NULL) CGEventTapEnable(g_eventTap, true);
|
return event;
|
}
|
|
if (type != kCGEventKeyDown && type != kCGEventKeyUp && type != kCGEventFlagsChanged) {
|
return event;
|
}
|
|
unsigned short keyCode = (unsigned short)CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode);
|
if (type == kCGEventFlagsChanged) {
|
handleModifierEvent(keyCode, CGEventGetFlags(event));
|
} else {
|
handleKeyEvent(keyCode, type == kCGEventKeyDown);
|
recordAltEvent(keyCode, type == kCGEventKeyDown ? 1 : 2, CGEventGetFlags(event));
|
}
|
return event;
|
}
|
|
static void startEventTapMonitor(void) {
|
if (g_monitorStarted) {
|
g_monitorLaunching = 0;
|
return;
|
}
|
g_monitorStartAttempts++;
|
|
CGEventMask eventMask = CGEventMaskBit(kCGEventKeyDown) |
|
CGEventMaskBit(kCGEventKeyUp) |
|
CGEventMaskBit(kCGEventFlagsChanged);
|
|
g_eventTap = CGEventTapCreate(kCGHIDEventTap,
|
kCGHeadInsertEventTap,
|
kCGEventTapOptionListenOnly,
|
eventMask,
|
keyboardEventTapCallback,
|
NULL);
|
if (g_eventTap == NULL) {
|
g_eventTapCreateFailures++;
|
g_monitorLaunching = 0;
|
return;
|
}
|
|
g_eventTapSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, g_eventTap, 0);
|
if (g_eventTapSource == NULL) {
|
g_eventTapCreateFailures++;
|
CFRelease(g_eventTap);
|
g_eventTap = NULL;
|
g_monitorLaunching = 0;
|
return;
|
}
|
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), g_eventTapSource, kCFRunLoopCommonModes);
|
CGEventTapEnable(g_eventTap, true);
|
g_monitorStarted = 1;
|
g_monitorLaunching = 0;
|
CFRunLoopRun();
|
g_monitorStarted = 0;
|
}
|
|
static int ensureAccessibility(void) {
|
NSDictionary* opts = @{(__bridge NSString*)kAXTrustedCheckOptionPrompt: @YES};
|
return AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts) ? 1 : 0;
|
}
|
|
static void beginHotkeyActivity(void) {
|
if (g_activityToken != nil) return;
|
g_activityToken = [[NSProcessInfo processInfo]
|
beginActivityWithOptions:NSActivityUserInitiatedAllowingIdleSystemSleep
|
reason:@"VoiceSnap global hotkey listener"];
|
}
|
|
static void* eventTapThreadMain(void* arg) {
|
(void)arg;
|
@autoreleasepool {
|
startEventTapMonitor();
|
}
|
g_monitorLaunching = 0;
|
return NULL;
|
}
|
|
static void startKeyMonitor(void) {
|
if (g_monitorStarted || g_monitorLaunching) return;
|
g_monitorLaunching = 1;
|
pthread_t thread;
|
int err = pthread_create(&thread, NULL, eventTapThreadMain, NULL);
|
if (err != 0) {
|
g_monitorLaunching = 0;
|
g_eventTapCreateFailures++;
|
return;
|
}
|
pthread_detach(thread);
|
}
|
|
// isModifierDown polls the current system modifier flags via CGEventSource.
|
// This works globally without needing event tap or NSEvent monitor callbacks.
|
static int isModifierDown(unsigned long long flagMask) {
|
CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateHIDSystemState);
|
return (flags & flagMask) ? 1 : 0;
|
}
|
|
static int isSideModifierDown(int keyCode) {
|
if (keyCode >= 0 && keyCode < 128) {
|
if (CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode)) return 1;
|
if (g_keysDown[keyCode] && g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.25) return 1;
|
if (g_keysDown[keyCode]) g_keysDown[keyCode] = 0;
|
if (g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.12) return 1;
|
}
|
return 0;
|
}
|
|
static int isKeyDown(int keyCode) {
|
if (keyCode >= 0 && keyCode < 128) {
|
if (CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode)) return 1;
|
if (g_keysDown[keyCode] && g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.25) return 1;
|
if (g_keysDown[keyCode]) g_keysDown[keyCode] = 0;
|
if (g_lastDownAt[keyCode] > 0 && nowSeconds() - g_lastDownAt[keyCode] < 0.12) return 1;
|
}
|
return 0;
|
}
|
|
static int isPhysicalKeyDown(int keyCode) {
|
if (keyCode < 0 || keyCode >= 128) return 0;
|
return CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode) ? 1 : 0;
|
}
|
|
static int isTrackedKeyDown(int keyCode) {
|
if (keyCode < 0 || keyCode >= 128) return 0;
|
return g_keysDown[keyCode] ? 1 : 0;
|
}
|
|
static int isRecentKeyDown(int keyCode) {
|
if (keyCode < 0 || keyCode >= 128 || g_lastDownAt[keyCode] <= 0) return 0;
|
return nowSeconds() - g_lastDownAt[keyCode] < 0.12 ? 1 : 0;
|
}
|
|
static double lastKeyDownAge(int keyCode) {
|
if (keyCode < 0 || keyCode >= 128 || g_lastDownAt[keyCode] <= 0) return -1;
|
return nowSeconds() - g_lastDownAt[keyCode];
|
}
|
|
static int isMonitorStarted(void) {
|
return g_monitorStarted;
|
}
|
|
static int isMonitorLaunching(void) {
|
return g_monitorLaunching;
|
}
|
|
static unsigned long long monitorStartAttempts(void) {
|
return g_monitorStartAttempts;
|
}
|
|
static unsigned long long eventTapCreateFailures(void) {
|
return g_eventTapCreateFailures;
|
}
|
|
static unsigned long long eventTapDisabledCount(void) {
|
return g_eventTapDisabledCount;
|
}
|
|
static unsigned long long altEventSeq(int index) {
|
if (index < 0 || index > 1) return 0;
|
return g_altEventSeq[index];
|
}
|
|
static int altEventDown(int index) {
|
if (index < 0 || index > 1) return 0;
|
return g_altEventDown[index];
|
}
|
|
static int altEventType(int index) {
|
if (index < 0 || index > 1) return 0;
|
return g_altEventType[index];
|
}
|
|
static unsigned long long altEventFlags(int index) {
|
if (index < 0 || index > 1) return 0;
|
return g_altEventFlags[index];
|
}
|
|
static double altEventAge(int index) {
|
if (index < 0 || index > 1 || g_altEventAt[index] <= 0) return -1;
|
return nowSeconds() - g_altEventAt[index];
|
}
|
*/
|
import "C"
|
|
import (
|
"fmt"
|
"time"
|
"voicesnap/internal/logger"
|
)
|
|
// macOS modifier flag masks for CGEventSourceFlagsState polling.
|
const (
|
maskControl = 0x40000 // kCGEventFlagMaskControl
|
maskShift = 0x20000 // kCGEventFlagMaskShift
|
maskOption = 0x80000 // kCGEventFlagMaskAlternate
|
maskCommand = 0x100000 // kCGEventFlagMaskCommand
|
maskCapsLock = 0x10000 // kCGEventFlagMaskAlphaShift
|
)
|
|
// Generic modifier VK codes use flag polling; left/right variants use physical
|
// key polling because CGEvent modifier flags do not distinguish sides.
|
var vkToFlagMask = map[int]C.ulonglong{
|
0x11: maskControl, // Ctrl
|
0x12: maskOption, // Alt
|
0x10: maskShift, // Shift
|
0x14: maskCapsLock, // Caps Lock
|
0x5B: maskCommand, // Cmd
|
0x5C: maskCommand, // Cmd
|
}
|
|
var vkToPhysicalModifier = map[int]int{
|
0xA2: 59, // L-Ctrl
|
0xA3: 62, // R-Ctrl
|
0xA4: 58, // L-Alt / Option
|
0xA5: 61, // R-Alt / Option
|
0xA0: 56, // L-Shift
|
0xA1: 60, // R-Shift
|
0x5B: 55, // L-Cmd
|
0x5C: 54, // R-Cmd
|
}
|
|
// macOS key code mapping for non-modifier keys.
|
var vkToMac = map[int]int{
|
0x20: 0x31, // Space
|
0x09: 0x30, // Tab
|
0x0D: 0x24, // Enter/Return
|
0x1B: 0x35, // Esc
|
}
|
|
type darwinListener struct {
|
lastAltSeq map[int]uint64
|
lastAltState map[int]bool
|
lastMonitorRetry time.Time
|
}
|
|
func newPlatformListener() Listener {
|
C.beginHotkeyActivity()
|
trusted := C.ensureAccessibility()
|
if trusted == 0 {
|
logger.Info("Accessibility permission not granted for current app identity — hotkey won't work until VoiceSnap is removed and re-added in System Settings > Privacy & Security > Accessibility")
|
} else {
|
logger.Info("Accessibility permission granted")
|
}
|
C.startKeyMonitor()
|
deadline := time.Now().Add(time.Second)
|
for C.isMonitorStarted() == 0 && C.isMonitorLaunching() != 0 && time.Now().Before(deadline) {
|
time.Sleep(50 * time.Millisecond)
|
}
|
if C.isMonitorStarted() == 0 {
|
logger.Error(
|
"Keyboard event tap not running yet; attempts=%d failures=%d launching=%t. Physical hotkey polling fallback is enabled.",
|
uint64(C.monitorStartAttempts()),
|
uint64(C.eventTapCreateFailures()),
|
C.isMonitorLaunching() != 0,
|
)
|
} else {
|
logger.Info(
|
"Keyboard event tap started; attempts=%d failures=%d disabled=%d",
|
uint64(C.monitorStartAttempts()),
|
uint64(C.eventTapCreateFailures()),
|
uint64(C.eventTapDisabledCount()),
|
)
|
}
|
return &darwinListener{
|
lastAltSeq: map[int]uint64{},
|
lastAltState: map[int]bool{},
|
lastMonitorRetry: time.Now(),
|
}
|
}
|
|
func (l *darwinListener) IsKeyDown(vk int) bool {
|
l.retryMonitorIfNeeded()
|
if keyCode, ok := vkToPhysicalModifier[vk]; ok {
|
down := C.isSideModifierDown(C.int(keyCode)) != 0
|
l.logAltDiagnostics(vk, keyCode, down)
|
return down
|
}
|
// Generic modifier keys: poll system flags directly.
|
if mask, ok := vkToFlagMask[vk]; ok {
|
return C.isModifierDown(mask) != 0
|
}
|
// Regular keys: check NSEvent-tracked state
|
macKey, ok := vkToMac[vk]
|
if !ok {
|
if vk >= 0x41 && vk <= 0x5A {
|
macKey = vkToMacAlpha(vk)
|
} else {
|
return false
|
}
|
}
|
return C.isKeyDown(C.int(macKey)) != 0
|
}
|
|
func (l *darwinListener) retryMonitorIfNeeded() {
|
if C.isMonitorStarted() != 0 || C.isMonitorLaunching() != 0 {
|
return
|
}
|
if time.Since(l.lastMonitorRetry) < 10*time.Second {
|
return
|
}
|
l.lastMonitorRetry = time.Now()
|
logger.Info(
|
"Keyboard event tap retry requested; attempts=%d failures=%d disabled=%d",
|
uint64(C.monitorStartAttempts()),
|
uint64(C.eventTapCreateFailures()),
|
uint64(C.eventTapDisabledCount()),
|
)
|
C.startKeyMonitor()
|
}
|
|
func (l *darwinListener) logAltDiagnostics(vk, keyCode int, down bool) {
|
index := altDiagnosticIndex(keyCode)
|
if index < 0 {
|
return
|
}
|
|
physicalDown := C.isPhysicalKeyDown(C.int(keyCode)) != 0
|
trackedDown := C.isTrackedKeyDown(C.int(keyCode)) != 0
|
recentDown := C.isRecentKeyDown(C.int(keyCode)) != 0
|
|
seq := uint64(C.altEventSeq(C.int(index)))
|
if seq != l.lastAltSeq[vk] {
|
l.lastAltSeq[vk] = seq
|
logger.Info(
|
"Hotkey event tap: key=%s vk=0x%X keyCode=%d seq=%d event=%s eventDown=%t pollDown=%t physicalDown=%t trackedDown=%t recentDown=%t flags=0x%X age=%.3fs tapStarted=%t",
|
GetKeyName(vk),
|
vk,
|
keyCode,
|
seq,
|
altEventTypeName(int(C.altEventType(C.int(index)))),
|
C.altEventDown(C.int(index)) != 0,
|
down,
|
physicalDown,
|
trackedDown,
|
recentDown,
|
uint64(C.altEventFlags(C.int(index))),
|
float64(C.altEventAge(C.int(index))),
|
C.isMonitorStarted() != 0,
|
)
|
}
|
|
if previous, ok := l.lastAltState[vk]; !ok || previous != down {
|
l.lastAltState[vk] = down
|
logger.Info(
|
"Hotkey poll: key=%s vk=0x%X keyCode=%d down=%t source=%s physicalDown=%t trackedDown=%t recentDown=%t tapStarted=%t",
|
GetKeyName(vk),
|
vk,
|
keyCode,
|
down,
|
hotkeySource(down, physicalDown, trackedDown, recentDown),
|
physicalDown,
|
trackedDown,
|
recentDown,
|
C.isMonitorStarted() != 0,
|
)
|
}
|
}
|
|
func hotkeySource(down, physicalDown, trackedDown, recentDown bool) string {
|
switch {
|
case !down:
|
return "none"
|
case physicalDown:
|
return "physical"
|
case trackedDown:
|
return "eventTap"
|
case recentDown:
|
return "recent"
|
default:
|
return "unknown"
|
}
|
}
|
|
func altDiagnosticIndex(keyCode int) int {
|
switch keyCode {
|
case 58:
|
return 0
|
case 61:
|
return 1
|
default:
|
return -1
|
}
|
}
|
|
func altEventTypeName(eventType int) string {
|
switch eventType {
|
case 1:
|
return "keyDown"
|
case 2:
|
return "keyUp"
|
case 3:
|
return "flagsChanged"
|
default:
|
return fmt.Sprintf("unknown(%d)", eventType)
|
}
|
}
|
|
func (l *darwinListener) IsAnyOtherKeyPressed(excludeVK int) bool {
|
return l.IsAnyOtherKeyPressedSince(excludeVK, time.Time{})
|
}
|
|
func (l *darwinListener) IsAnyOtherKeyPressedSince(excludeVK int, since time.Time) bool {
|
excludeGroup := modifierGroup(excludeVK)
|
|
modifiers := []int{0x11, 0xA2, 0xA3, 0x12, 0xA4, 0xA5, 0x10, 0xA0, 0xA1}
|
for _, k := range modifiers {
|
if k == excludeVK {
|
continue
|
}
|
if excludeGroup != 0 && modifierGroup(k) == excludeGroup {
|
continue
|
}
|
if l.wasPressedSince(k, since) {
|
logger.Info("Hotkey combination source: key=%s vk=0x%X", GetKeyName(k), k)
|
return true
|
}
|
}
|
for vk := 0x41; vk <= 0x5A; vk++ {
|
if vk == excludeVK {
|
continue
|
}
|
if l.wasPressedSince(vk, since) {
|
logger.Info("Hotkey combination source: key=%s vk=0x%X", GetKeyName(vk), vk)
|
return true
|
}
|
}
|
return false
|
}
|
|
func (l *darwinListener) wasPressedSince(vk int, since time.Time) bool {
|
if since.IsZero() {
|
return l.IsKeyDown(vk)
|
}
|
|
keyCode, ok := macKeyCodeForVK(vk)
|
if !ok {
|
return false
|
}
|
age := float64(C.lastKeyDownAge(C.int(keyCode)))
|
if age < 0 {
|
return false
|
}
|
return time.Since(since).Seconds()-age >= -0.02
|
}
|
|
func modifierGroup(vk int) int {
|
switch vk {
|
case 0x11, 0xA2, 0xA3:
|
return 1
|
case 0x12, 0xA4, 0xA5:
|
return 2
|
case 0x10, 0xA0, 0xA1:
|
return 3
|
default:
|
return 0
|
}
|
}
|
|
func isGenericModifier(vk int) bool {
|
return vk == 0x11 || vk == 0x12 || vk == 0x10
|
}
|
|
func vkToMacAlpha(vk int) int {
|
alphaMap := map[int]int{
|
0x41: 0x00, 0x42: 0x0B, 0x43: 0x08, 0x44: 0x02,
|
0x45: 0x0E, 0x46: 0x03, 0x47: 0x05, 0x48: 0x04,
|
0x49: 0x22, 0x4A: 0x26, 0x4B: 0x28, 0x4C: 0x25,
|
0x4D: 0x2E, 0x4E: 0x2D, 0x4F: 0x1F, 0x50: 0x23,
|
0x51: 0x0C, 0x52: 0x0F, 0x53: 0x01, 0x54: 0x11,
|
0x55: 0x20, 0x56: 0x09, 0x57: 0x0D, 0x58: 0x07,
|
0x59: 0x10, 0x5A: 0x06,
|
}
|
if code, ok := alphaMap[vk]; ok {
|
return code
|
}
|
return 0
|
}
|
|
func macKeyCodeForVK(vk int) (int, bool) {
|
if keyCode, ok := vkToPhysicalModifier[vk]; ok {
|
return keyCode, true
|
}
|
if keyCode, ok := vkToMac[vk]; ok {
|
return keyCode, true
|
}
|
if vk >= 0x41 && vk <= 0x5A {
|
return vkToMacAlpha(vk), true
|
}
|
return 0, false
|
}
|