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