【Go】karabiner-elementの代用アプリを自作してみた

## USキーボードを使っています

社用PCも私物のPCも、MacBookのUSキーボード配列のものを利用しています。

USキーボードには良いところ、悪いところありますが、
日本語を日常使いしている身にとって、致命的なのは「かな変換」、「英数変換」キーが無いことだと思います。

最近のMacOSでは、fnキーを単体で押したり、 control + spaceで入力ソースを切り替えることができるようになっています。

しかし、最も使いやすい形は右コマンドが「かな変換」、左コマンドが「英数変換」のようにJISキーボードに近い操作感で変換ができることです。

## キーバインド変換用のサードパーティアプリ
上記のようにコマンドキーに変換を割り当てるためのサードパーティアプリが存在します。
https://karabiner-elements.pqrs.org/

karabiner-elementsはかなり高機能なアプリケーションで、コマンドキーに限らず他のキーも自由度高くカスタマイズすることができます。


## karabiner-elements設定がおかしくなった
意図したわけではないのですが、自分の何等かの操作によって、コマンドキーとオプションキーが入れ替わるといった事象がありました(現在は解決済み、原因は後述)。
この事象は外部キーボードに限り発現していました。

アプリをダウングレードしても、OSのキーボード設定をいじっても解決しなかったため、karabiner-elementsの代用アプリケーションを自作しようという発想になりました。


## 技術構成
OSのキーボードAPIを操作する -> Objective-C
CLIを作る -> Go

ということで、GoからObjective-Cを呼ぶ構成で作成します。

## 作ったもの
https://github.com/shuyaeer/joker

こちらのコマンドでダウンロードできます。
```bash
$ go install github.com/shuyaeer/joker@latest
```


cmd/main.goはこんな感じです。
```go
package main

/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices -framework Carbon
#include <Cocoa/Cocoa.h>
#include <ApplicationServices/ApplicationServices.h>
#include <Carbon/Carbon.h>
#include <stdlib.h>
#include <stdbool.h>
#include <dispatch/dispatch.h>

// キーコードの定義
#define LEFT_COMMAND_KEYCODE 55
#define RIGHT_COMMAND_KEYCODE 54

// 入力ソース切り替え関数
void switchToEnglish() {
    TISInputSourceRef source = TISCopyInputSourceForLanguage(CFSTR("en-US"));
    if (source) {
        TISSelectInputSource(source);
        CFRelease(source);
    }
}

void switchToJapanese() {
    TISInputSourceRef source = TISCopyInputSourceForLanguage(CFSTR("ja-JP"));
    if (source) {
        TISSelectInputSource(source);
        CFRelease(source);
    }
}

// グローバル変数でCommandキーの状態を管理
static bool leftCommandPressed = false;
static bool rightCommandPressed = false;

// タイマーのディスパッチキュー
static dispatch_queue_t timerQueue;

// タイマーの期間（ナノ秒単位）
#define TIMER_DURATION 50 * NSEC_PER_MSEC // 100ミリ秒

// タイマー設定関数
void setupTimers() {
    timerQueue = dispatch_queue_create("com.example.keytimer", DISPATCH_QUEUE_SERIAL);
}

// 左Commandキーのタイマー開始関数
void startLeftCommandTimer() {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, TIMER_DURATION), timerQueue, ^{
        if (leftCommandPressed) {
            switchToEnglish();
            leftCommandPressed = false;
        }
    });
}

// 右Commandキーのタイマー開始関数
void startRightCommandTimer() {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, TIMER_DURATION), timerQueue, ^{
        if (rightCommandPressed) {
            switchToJapanese();
            rightCommandPressed = false;
        }
    });
}

// キーイベントコールバック関数
static CGEventRef keyCallback(CGEventTapProxy proxy, CGEventType type, CGEventRef event, void *refcon) {
    if (type == kCGEventFlagsChanged) {
        CGEventFlags flags = CGEventGetFlags(event);
        CGKeyCode keycode = (CGKeyCode)CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode);

        // 左Commandキーの押下/離脱
        if (keycode == LEFT_COMMAND_KEYCODE) {
            if (!(flags & kCGEventFlagMaskCommand)) {
                leftCommandPressed = true;
                startLeftCommandTimer();
            } else {
                leftCommandPressed = false;
            }
        }

        // 右Commandキーの押下/離脱
        if (keycode == RIGHT_COMMAND_KEYCODE) {
            if (!(flags & kCGEventFlagMaskCommand)) {
                rightCommandPressed = true;
                startRightCommandTimer();
            } else {
                rightCommandPressed = false;
            }
        }
    } else if (type == kCGEventKeyDown || type == kCGEventKeyUp) {
        CGKeyCode keycode = (CGKeyCode)CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode);

        // Commandキー以外のキーが押下された場合、Commandキーの単独押下フラグをリセット
        if (keycode != LEFT_COMMAND_KEYCODE && keycode != RIGHT_COMMAND_KEYCODE) {
            leftCommandPressed = false;
            rightCommandPressed = false;
        }
    }

    return event;
}

// イベントタップの設定と開始
void startKeyTap() {
    setupTimers();

    // イベントマスクの設定
    CGEventMask mask = CGEventMaskBit(kCGEventFlagsChanged) | CGEventMaskBit(kCGEventKeyDown) | CGEventMaskBit(kCGEventKeyUp);

    // イベントタップの作成
    CFMachPortRef tap = CGEventTapCreate(kCGSessionEventTap,
                                        kCGHeadInsertEventTap,
                                        0,
                                        mask,
                                        keyCallback,
                                        NULL);
    if (!tap) {
        NSLog(@"Failed to create event tap");
        exit(1);
    }

    // ループソースの作成
    CFRunLoopSourceRef runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, kCFRunLoopCommonModes);
    CGEventTapEnable(tap, true);

    // ループの開始
    CFRunLoopRun();
}
*/
import "C"

import (
	"fmt"
)

func main() {
	fmt.Println("Starting key tap...")
	go C.startKeyTap()
	select {}
}
```

ほぼObjective-Cの記述になっています。


GoからObjective-Cを呼んでいる処理は以下です。組み込みに`C`というライブラリがあり、利用しています。
```go
import "C"

import (
	"fmt"
)

func main() {
	fmt.Println("Starting key tap...")
	go C.startKeyTap()
	select {}
}
```

## 実行
```bash
$ joker
Starting key tap...
Current Input Source: ABC (en)
2024-12-24 17:41:23.406 joker[54764:10070093] Keycode: 54 # 右コマンドを押した
2024-12-24 17:41:23.565 joker[54764:10070093] Keycode: 54　# 右コマンドを離した
Current Input Source: Hiragana (Google) (ja)
```
オプション等はないです。シンプルにコンソールに`joker`と入力することで、キーボードの入力監視を開始します。

ソースが`Current Input Source: ABC (en)`から`Current Input Source: Hiragana (Google) (ja)`に切り替わっていることが確認できます。


## 感想
GoからObjective-Cを呼び出すことで、MacOSのキーボードAPIを扱うことができるというのは感動でした。
込み入ったものを作るのには向かない構成ではありますが、他にもメニューバーやカーソル等もGoから制御できると、作れるもの可能性が一気に広がった感覚があります。

後から気がついたのですが、Goのみで作成されたライブラリでも、キーボードの操作はできるようです。
https://github.com/gvalkov/golang-evdev


## おまけ
このCLIを作るきっかけとなった、karabiner-elementsにて外付けキーボードのバインドがおかしくなるという事象についてですが、現在は解決しています。



`~/.config/karabiner/karabiner.json`に設定ファイルがあり、外部キーボードに関する記述を消すことで、解消されました。


```json
"devices": [	"name": "Default profile",
{	
"identifiers": {	
"is_keyboard": true,	
"product_id": 591,	
"vendor_id": 1452	
},	
"treat_as_built_in_keyboard": true	
},	
{	
"identifiers": {	
"is_keyboard": true,	
"is_pointing_device": true,	
"product_id": 591,	
"vendor_id": 1452	
},	
"treat_as_built_in_keyboard": true	
}	
],
```

OSとkarabiner-elementsのバージョンを同時期に更新したのですが、それが原因だったのかなぁと思っております。