Ariver
2026-05-18 ca0d8f786a86122e742914b58a4a0eacded64b61
fix: stabilize hidden-window hotkey polling
4 files modified
200 ■■■■ changed files
TODO.md 11 ●●●●● patch | view | raw | blame | history
VoiceSnapGo/app.go 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/build/darwin/Info.plist 2 ●●● patch | view | raw | blame | history
VoiceSnapGo/internal/hotkey/hotkey_darwin.go 185 ●●●● patch | view | raw | blame | history
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`。
VoiceSnapGo/app.go
@@ -27,7 +27,7 @@
const (
    appVersion        = "2.1.1"
    appBuild          = "20260518.0211"
    appBuild          = "20260518.0230"
    appDisplayVersion = appVersion + " (build " + appBuild + ")"
    appName           = "VoiceSnap"
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>
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"
    }
}