From ca0d8f786a86122e742914b58a4a0eacded64b61 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 02:34:10 +0800
Subject: [PATCH] fix: stabilize hidden-window hotkey polling
---
TODO.md | 11 ++
VoiceSnapGo/app.go | 2
VoiceSnapGo/build/darwin/Info.plist | 2
VoiceSnapGo/internal/hotkey/hotkey_darwin.go | 185 ++++++++++++++++++++++++++++++++++++++++------
4 files changed, 174 insertions(+), 26 deletions(-)
diff --git a/TODO.md b/TODO.md
index f4c281b..aa3205e 100644
--- a/TODO.md
+++ b/TODO.md
@@ -17,6 +17,17 @@
## Done
+- [2026-05-18] 修复 VoiceSnap 设置窗口不可见时右 Alt 全局热键无法触发的问题。
+ - 观察: 设置窗口在前台可触发,窗口隐藏或被遮挡后按右 Alt 没有热键日志。
+ - 修复: macOS 热键监听改为专用线程运行,并在 event tap 未启动时自动重试。
+ - 修复: 右/左修饰键检测增加物理按键状态轮询兜底,避免只依赖窗口前台状态下的 event tap;同时去掉 Option 组 flags 对左右 Alt 的兜底混用,继续保持右 Alt/左 Alt 严格区分。
+ - 修复: 启动时开启系统 activity,降低隐藏窗口后被 App Nap 影响的概率。
+ - 日志增强: 右 Alt 诊断日志现在记录 `source`、`physicalDown`、`trackedDown`、`recentDown`、event tap 启动尝试/失败/禁用次数。
+ - build: `20260518.0230`
+ - 输出: `VoiceSnapGo/build/local/arm64/VoiceSnap.app`
+ - 输出: `VoiceSnapGo/build/local/arm64/VoiceSnap-2.1.1-build20260518.0230-arm64-local.dmg`
+ - 兼容输出: `VoiceSnapGo/build/local/arm64/VoiceSnap-2.1.1-arm64-local.dmg` 已替换为同一份新包。
+ - 已安装到 `/Applications/VoiceSnap.app`;启动日志验证 build `20260518.0230`,辅助功能权限已通过,event tap 已启动。
- [2026-05-18] 将本地 macOS 打包从 ad-hoc 签名改为固定本地 Code Signing 身份,减少辅助功能权限失效。
- 新增 `VoiceSnapGo/scripts/build-local-macos.sh`,用于本地构建、稳定签名、生成 DMG,可选安装到 `/Applications`。
- 自动创建/复用钥匙串身份 `VoiceSnap Local Code Signing`,签名 `.app`、内部 dylib 和 `.dmg`。
diff --git a/VoiceSnapGo/app.go b/VoiceSnapGo/app.go
index efd4a6c..ceab12c 100755
--- a/VoiceSnapGo/app.go
+++ b/VoiceSnapGo/app.go
@@ -27,7 +27,7 @@
const (
appVersion = "2.1.1"
- appBuild = "20260518.0211"
+ appBuild = "20260518.0230"
appDisplayVersion = appVersion + " (build " + appBuild + ")"
appName = "VoiceSnap"
diff --git a/VoiceSnapGo/build/darwin/Info.plist b/VoiceSnapGo/build/darwin/Info.plist
index a448a21..0039b15 100755
--- a/VoiceSnapGo/build/darwin/Info.plist
+++ b/VoiceSnapGo/build/darwin/Info.plist
@@ -17,7 +17,7 @@
<key>CFBundleShortVersionString</key>
<string>2.1.1</string>
<key>CFBundleVersion</key>
- <string>20260518.0211</string>
+ <string>20260518.0230</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSMicrophoneUsageDescription</key>
diff --git a/VoiceSnapGo/internal/hotkey/hotkey_darwin.go b/VoiceSnapGo/internal/hotkey/hotkey_darwin.go
index 1eecdb1..710e9d6 100755
--- a/VoiceSnapGo/internal/hotkey/hotkey_darwin.go
+++ b/VoiceSnapGo/internal/hotkey/hotkey_darwin.go
@@ -7,16 +7,22 @@
#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();
@@ -70,19 +76,13 @@
if (mask == 0) return;
BOOL physicalDown = CGEventSourceKeyState(kCGEventSourceStateHIDSystemState, (CGKeyCode)keyCode);
- if (physicalDown) {
- handleKeyEvent(keyCode, YES);
- recordAltEvent(keyCode, 3, flags);
- return;
- }
-
- BOOL groupDown = (flags & mask) ? YES : NO;
- handleKeyEvent(keyCode, groupDown ? YES : NO);
+ 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;
}
@@ -102,7 +102,11 @@
}
static void startEventTapMonitor(void) {
- if (g_monitorStarted) return;
+ if (g_monitorStarted) {
+ g_monitorLaunching = 0;
+ return;
+ }
+ g_monitorStartAttempts++;
CGEventMask eventMask = CGEventMaskBit(kCGEventKeyDown) |
CGEventMaskBit(kCGEventKeyUp) |
@@ -114,19 +118,27 @@
eventMask,
keyboardEventTapCallback,
NULL);
- if (g_eventTap == NULL) return;
+ 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) {
@@ -134,10 +146,33 @@
return AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)opts) ? 1 : 0;
}
-static void startKeyMonitor(void) {
- dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 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.
@@ -149,7 +184,9 @@
static int isSideModifierDown(int keyCode) {
if (keyCode >= 0 && keyCode < 128) {
- if (g_keysDown[keyCode]) return 1;
+ 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;
@@ -157,10 +194,27 @@
static int isKeyDown(int keyCode) {
if (keyCode >= 0 && keyCode < 128) {
- if (g_keysDown[keyCode]) return 1;
+ 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) {
@@ -170,6 +224,22 @@
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) {
@@ -245,11 +315,13 @@
}
type darwinListener struct {
- lastAltSeq map[int]uint64
- lastAltState map[int]bool
+ 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")
@@ -257,19 +329,34 @@
logger.Info("Accessibility permission granted")
}
C.startKeyMonitor()
- time.Sleep(150 * time.Millisecond)
+ 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 failed to start; check Accessibility permission")
+ 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")
+ 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{},
+ 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)
@@ -291,17 +378,38 @@
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 flags=0x%X age=%.3fs tapStarted=%t",
+ "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,
@@ -309,6 +417,9 @@
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,
@@ -317,7 +428,33 @@
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 tapStarted=%t", GetKeyName(vk), vk, keyCode, down, C.isMonitorStarted() != 0)
+ 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"
}
}
--
Gitblit v1.9.3