解决 MacBook 键盘双击问题

2017.09.15
SF-Zhou

手上的 MacBook Pro 是 2016 年年底买的,至今一年都没到。前一段时间就出现了 b 键有时候按一下出来两个字符的问题。最近几天 n 键也出现了相同的问题。这个事件还是有概率会发生,打字的时候让人非常不爽。

我在网上查了一下,出现类似问题的不止我一个人。看起来是新出的蝴蝶键盘品控的问题。有人说拿去送修了,修完了还是有这个问题。所以我就一直忍着,忍着忍着忍着忍不了了😂哪有打字,打到一半,发现多了个 n,再回来删除的道理!

所以,今天要解决这个问题!

1. 思路

  1. 硬件方案
    1. 送修:大概率修不好,而且麻烦
    2. 外接键盘:不方便,出门带键盘麻烦
  2. 软件方案

思索一下软件方案。首先分析双击的事件的原因,猜测是按下 n 键后,触发 KeyDown,释放 n 键后,触发 KeyUp;而后手抬起的过程中,意外地再一次触发了 KeyDownKeyUp。这中间的时间很短。

如果要解决这个问题,需要实现的功能就是:分析当前的 KeyDown 事件,与上一次该键的 KeyUp 时间间隔。如果异常的短,说明是键盘导致的双击事件,则忽略该 KeyDown 信号。

有了思路之后,再考虑如何实现该功能。macOS 上有一大批键盘相关的软件,包括收费的 Keyboard Maestro,开源的 Karabiner-Elements,还有针对自定义快捷键的 Hammerspoon。笔者都尝试了一遍之后,发现并不容易在这些软件上实现需求。

只能靠自己写了。幸运的是,我是个软件工程师 :D

2. 实现

首先,搜索相关的方案。需求为拦截全局的键盘信号,搜索词为 "macOS global keyboard event"。

看了一些搜索结果之后,发现了一段不错的代码:Global keyboard hook for OSX,作者为 quietcricket,四年前上传的。代码实现的功能为调换 a 键和 z 键。按照代码中提示的编译命令编译,成功。

执行,提示 Accessibility 问题,一般涉及到全局键盘的软件都会需要 Accessibility 授权。在设置中找到 Security & PrivacyAccessibility。笔者是在 iTerm2 中执行编译后的 binary 的,所以打开 iTerm2 的授权。再次执行,OK。测试,全局的 a 键和 z 键确实被调换了,包括快捷键。

阅读代码。代码中:

// Swap 'a' (keycode=0) and 'z' (keycode=6).
if (keycode == (CGKeyCode)0)
  keycode = (CGKeyCode)6;
else if (keycode == (CGKeyCode)6)
  keycode = (CGKeyCode)0;

实现了调换的功能。程序使用了 C 编写,可以直接将代码后缀名改成 C++,使用 G++ 编译。加上打印 keycode 的功能,就可以看到所有按键的 keycode 值了。经过测试,n 键和 b 键的 keycode 值为 45 和 11。

使用 chrono 获取按键 KeyUp 时的时间点,并记录;在 KeyDown 时判断,如果与上次 KeyUp 的时间间隔过短,则忽略该事件。最终代码如下:

// alterkeys.c
// http://osxbook.com
//
// You need superuser privileges to create the event tap, unless accessibility
// is enabled. To do so, select the "Enable access for assistive devices"
// checkbox in the Universal Access system preference pane.

// modified by SF-Zhou
// To: Kill Double Typing on MacBook
// Complile: g++ -O2 -Wall -o kill_double_typing kill_double_typing.cpp -framework ApplicationServices
// Run: ./kill_double_typing

#include <ApplicationServices/ApplicationServices.h>
#include <iostream>
#include <chrono>
#include <unordered_map>
using namespace std;

typedef chrono::time_point<std::chrono::high_resolution_clock> Time;
typedef long long ll;

unordered_map<CGKeyCode, Time> last_time;

Time time_now() {
  return chrono::high_resolution_clock::now();
}

// This callback will be invoked every time there is a keystroke.
CGEventRef myCGEventCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
  // Paranoid sanity check.
  if ((type != kCGEventKeyDown) && (type != kCGEventKeyUp)) {
    return event;
  }

  // The incoming keycode.
  CGKeyCode keycode = (CGKeyCode)CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode);

  // printf("%d\n", keycode);  // print keycode
  if (keycode == 11 /* b */ || keycode == 45 /* n */) {
    if (type == kCGEventKeyUp) {
      last_time[keycode] = time_now();
    } else {
      if (last_time.count(keycode)) {
        ll microseconds = chrono::duration_cast<chrono::microseconds>(
          time_now() - last_time[keycode]
        ).count();

        // ignore if time less than 30ms
        if (microseconds < 30000) {
          return NULL;
        }
      }
    }
  }

  // Set the modified keycode field in the event.
  CGEventSetIntegerValueField(event, kCGKeyboardEventKeycode, (int64_t)keycode);

  // We must return the event for it to be useful.
  return event;
}

int main(void) {
  CFMachPortRef      eventTap;
  CGEventMask        eventMask;
  CFRunLoopSourceRef runLoopSource;

  // Create an event tap. We are interested in key presses.
  eventMask = ((1 << kCGEventKeyDown) | (1 << kCGEventKeyUp));
  eventTap = CGEventTapCreate(kCGSessionEventTap, kCGHeadInsertEventTap, 0, eventMask, myCGEventCallback, NULL);
  if (!eventTap) {
      fprintf(stderr, "failed to create event tap\n");
      exit(1);
  }

  // Create a run loop source.
  runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0);

  // Add to the current run loop.
  CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes);

  // Enable the event tap.
  CGEventTapEnable(eventTap, true);

  // Set it all running.
  CFRunLoopRun();

  // In a real program, one would have arranged for cleaning up.

  exit(0);
}

将上述代码命名为 kill_double_typing.cpp,编译:

g++ -O2 -Wall -o kill_double_typing kill_double_typing.cpp -framework ApplicationServices

编译后得到可执行文件,可以使用 nohup 命令,将其设为后台执行。做得更复杂一点的,可以设置为自动启动,就自己搜索啦。

3. 总结

目前只是一个非常初级的版本,还有很多可以调优的地方,笔者会慢慢改进这个小程序的。

软件的方式是很有局限性的,最好的方案,当然是提高苹果的品控啦,不过我说是没用的😂。