Linux用キーリマッパーを作る技術

Nagano.rb #9

2022-02-26

とみたまさひろ

自己紹介

Linux用キーリマッパーを作った

Linux デスクトップ環境を使ってる人向け

経緯

11月に転職して人生初 Mac

キーマップに慣れない

でも Mac のキーマップの方が良さそう
(Ctrl+N や Ctrl+P がブラウザに取られない)

仕事以外で使ってる Linux でも Mac と同じにしよう!

「最強のキーリマッパー xremap」
https://k0kubun.hatenablog.com/entry/xremap

便利!

Ctrl-N を ↓
Ctrl-P を ↑
Ctrl-F を →
Ctrl-B を ←
Alt-[A-Z] を Ctrl-[A-Z]

みたいな感じにすれば良さそう

Ctrl-K は普通は行末まで削除

でも日本語入力時には Ctrl-K はカタカナ変換にしたい

できなそう…🤔

じゃあ自分で作ってみるか

Rkremap

https://github.com/tmtm/rkremap

アプリではなくライブラリ

設定ファイルではなくプログラムを書く必要あり

YAML はつらい…

Ruby の DSL もいいかも…

だったら Ruby プログラム書けばいいんじゃね

実行時に要root権限

ユーザーを input グループに追加すればいいんだけど
セキュリティ的にちょっとこわいかも

sudo なしでやるには
https://github.com/k0kubun/xremap#usage
「Option 2: Run xremap without sudo」

require 'rkremap'
include Rkremap::KeyCode
rk = Rkremap.new
rk.grab = true
rk.x11 = true
rk.start do |code, mod, app|
  # Emacs や端末ではそのまま
  if app.class_name == 'Emacs' || app.class_name =~ /terminal/i
    rk.key(code, mod)
    next
  end
  # ALT+[A-Z] は Ctrl+[A-Z] に変換
  if (mod[KEY_LEFTALT] || mod[KEY_RIGHTALT]) &&
     Rkremap::CODE_KEY[code] =~ /\AKEY_[A-Z]\z/
    mod[KEY_LEFTALT] = mod[KEY_RIGHTALT] = false
    mod[KEY_LEFTCTRL] = true
    rk.key(code, mod)
    next
  end
end

Ctrl-K 問題

日本語入力中かどうかは fcitx-remote コマンドで判定

while :; do
  if [ $(fcitx-remote) -eq 2 ]; then
    touch /tmp/fcitx-enabled
  else
    rm -f /tmp/fcitx-enabled
  fi
  sleep 0.1
done

状態ファイルの有無で分岐

  if mod[KEY_LEFTCTRL] || mod[KEY_RIGHTCTRL]
    # Ctrl+K/I/O は日本語変換時はそのまま
    if code == KEY_K && File.exist?('/tmp/fcitx-enabled')
      rk.key(code, mod)
      next
    end
    # Ctrl+K は行末まで削除
    if code == KEY_K
      rk.key(KEY_END, mod_disable_all.merge({KEY_LEFTSHIFT => true}))
      rk.key(KEY_X, mod_disable_all.merge({KEY_LEFTCTRL => true}))
      next
    end
    ...

キーロガー的なやつ

require 'rkremap'
def code2key(code)
  Rkremap::CODE_KEY[code].to_s.sub(/\AKEY_/, '')
end
rk = Rkremap.new
rk.grab = false
rk.x11 = true
rk.start do |code, mod, app|
  key = (mod.select{|_, v| v}.keys + [code]).map{|c| code2key(c)}.join('-')
  key << " at #{app.title} [#{app.class_name}]" if rk.x11
  puts key
end

キーリマッパーの構成要素技術

キー入力情報

/dev/input/event* から24バイト読む

struct input_event {
  struct timeval time;  // イベント発生日時
  // struct timeval { long int tv_usec, long int tv_nsec };
  unsigned short type;  // イベントタイプ
  unsigned short code;  // キーコード(キーイベントの場合)
  unsigned int value;   // 0:release / 1:press / 2:repeat
};

Rubyで(要root)

ev = File.open('/dev/input/event3')
raw = ev.sysread(24)
sec, usec, type, code, value = raw.unpack('Q!Q!SSl')

ThinkPad のキーボードで A を押して離すと:

type       code       value
EV_MSC(4)  4          30   # よくわからん
EV_KEY(1)  KEY_A(30)  1    # 'A' 押す
EV_SYN(0)  0          0    # 区切り
EV_MSC(4)  4          30   # よくわからん
EV_KEY(1)  KEY_A(30)  0    # 'A' 離す
EV_SYN(0)  0          0    # 区切り

Ctrl や Alt 等の修飾キーも普通のキーと同じ

Ctrl+A (EV_KEY だけ抜粋)

EV_KEY(1)  KEY_LEFTCTRL(29)  1  # 'CTRL' 押す
EV_KEY(1)  KEY_A(30)         1  # 'A' 押す
EV_KEY(1)  KEY_A(30)         0  # 'A' 離す
EV_KEY(1)  KEY_LEFTCTRL(29)  0  # 'CTRL' 離す

キーイベントは読めるけどアプリにも渡る

GRAB すればアプリに渡さず横取りできる

ev.ioctl(1074021776, 1)  # EVIOCGRAB

キー入力が効かなくなるので注意!

デバイスファイルの選択

evtest

% sudo evtest
No device specified, trying to scan all of /dev/input/event*
Available devices:
/dev/input/event0:  Lid Switch
/dev/input/event1:  Sleep Button
/dev/input/event2:  Power Button
/dev/input/event3:  AT Translated Set 2 keyboard
/dev/input/event4:  Video Bus
/dev/input/event5:  Synaptics TM3145-003
/dev/input/event6:  ThinkPad Extra Buttons
/dev/input/event7:  HDA Intel PCH Dock Mic
/dev/input/event8:  HDA Intel PCH Mic
/dev/input/event9:  HDA Intel PCH Dock Headphone
/dev/input/event10: HDA Intel PCH Headphone
/dev/input/event11: HDA Intel PCH HDMI/DP,pcm=3
/dev/input/event12: HDA Intel PCH HDMI/DP,pcm=7
/dev/input/event13: HDA Intel PCH HDMI/DP,pcm=8
/dev/input/event14: HDA Intel PCH HDMI/DP,pcm=9
/dev/input/event15: HDA Intel PCH HDMI/DP,pcm=10
/dev/input/event16: TPPS/2 IBM TrackPoint
/dev/input/event17: Integrated Camera: Integrated C

キーボードデバイスかどうか

EV_KEY = 0x01  # /usr/include/linux/input-event-codes.h より
buf = ''
ev.ioctl(2147566880, buf)  # EVIOCGBIT(0, 1)
buf[0].ord & EV_KEY  #=> 1 ならキーボードデバイス

キー A, Z に対応しているか

KEY_A = 30
KEY_Z = 44
ev.ioctl(2153792801, buf)  # EVIOCGBIT(EV_KEY, (KEY_MAX-1)/8+1)
buf.unpack('C*')[KEY_A/8][KEY_A%8] != 0  #=> 'A' に対応
buf.unpack('C*')[KEY_Z/8][KEY_Z%8] != 0  #=> 'Z' に対応

仮想キーボードデバイスの作成

/dev/uinput で仮想入力デバイスを作れる(要root)

udev = File.open('/dev/uinput', 'w')
udev.ioctl(1074025828, EV_KEY)  # UI_SET_EVBIT   キーデバイス
udev.ioctl(1074025829, KEY_A)   # UI_SET_KEYBIT  KEY_A を入力可能
udev.ioctl(1074025829, KEY_Z)   # UI_SET_KEYBIT  KEY_Z を入力可能
setup = [0x03, 0x1234, 0x5678, 1, 'name', 0].pack('SSSSZ80L')
  # デバイス情報はてきとー
udev.ioctl(1079792899, setup)   # UI_DEV_SETUP   セットアップ
udev.ioctl(21761)               # UI_DEV_CREATE  作成

作られたデバイスを evtest で見てみる

% sudo evtest /dev/input/event19
Input driver version is 1.0.1
Input device ID: bus 0x3 vendor 0x1234 product 0x5678 version 0x1
Input device name: "name"
Supported events:
  Event type 0 (EV_SYN)
  Event type 1 (EV_KEY)
    Event code 30 (KEY_A)
    Event code 44 (KEY_Z)

AZ しか入力できないデバイス

キー入力イベントの作成

A を押して離す

# 時刻は不要
udev.syswrite(['', EV_KEY, KEY_A, 1].pack('a16SSl'))  # push A
udev.syswrite(['', EV_SYN, 0, 0].pack('a16SSl'))      # 区切り

udev.syswrite(['', EV_KEY, KEY_A, 0].pack('a16SSl'))  # release A
udev.syswrite(['', EV_SYN, 0, 0].pack('a16SSl'))      # 区切り

毎秒 A-Z をランダムに書き込む迷惑なやつ

keys = ('A'..'Z').map{eval "KEY_#{_1}"}
while true
  key = keys.sample
  udev.syswrite(['', EV_KEY, key, 1].pack('a16SSl'))
  udev.syswrite(['', EV_SYN, 0, 0].pack('a16SSl'))
  udev.syswrite(['', EV_KEY, key, 0].pack('a16SSl'))
  udev.syswrite(['', EV_SYN, 0, 0].pack('a16SSl'))
  sleep 1
end

ioctl とか read / write じゃなくて libevdev (evdev gem) もあるんでそれを使うのも良さそう

キー変換処理まとめ

  • /dev/input/event* を GRAB してキーイベントを読む
  • イベントを加工
  • /dev/uinput で作成したデバイスに書き込む

X のウィンドウのタイトルを得る

特定のアプリだけで有効にしたいとか無効にしたいとか

X11 で入力フォーカスがあたってるアプリ名の取得

C の場合(ざっくりと):

  1. XGetInputFocus() でフォーカス Window 取得
  2. XGetClassHint() で Window のクラス名を取得
  3. クラス名が得られたら XGetWMName() でウィンドウタイトルを得る
  4. クラス名が NULL なら XQueryTree() で親 Window を得て 2 に戻る

Ruby には良さそうな X11 ライブラリがなさそう

X11 の全機能を網羅するのは大変そうだし

% ls /usr/share/man/man3 | grep -c '^X.*\.3\.gz$'
1231

C 拡張を書くのもアレなので

Fiddle で libX11 から必要な機能をつまみ食い

Fiddle

Ruby から C のライブラリ関数を呼び出せる

コンパイル不要

require 'fiddle/import'
module C
  extend Fiddle::Importer
  dlload 'libc.so.6'
  extern 'int atoi(const char *nptr)'
end
p C.atoi("123")  #=> 123

構造体やポインタも扱える

require 'fiddle/import'
module C
  extend Fiddle::Importer
  dlload 'libc.so.6'
  typealias 'time_t', 'long int'
  typealias 'suseconds_t', 'long int'
  Timeval = struct(['time_t tv_sec', 'suseconds_t tv_usec'])
  extern 'int gettimeofday(struct timeval *tv, struct timezone *tz)'
end
timeval = C::Timeval.malloc(Fiddle::RUBY_FREE)  # GC時に解放される
C.gettimeofday(timeval, nil)
p timeval.tv_sec   #=> 1970-01-01 00:00:00 UTC からの経過秒数
p timeval.tv_usec  #=> マイクロ秒

必要な関数のみ使えるようにして

module X11
  extend Fiddle::Importer
  dlload 'libX11.so.6'
  typealias 'XID', 'unsigned long'
  typealias 'Window', 'XID'
  typealias 'Status', 'int'
  typealias 'Atom', 'unsigned long'
  Window = struct ['Window window']
  Pointer = struct ['void *ptr']
  XClassHint = struct ['char *name', 'char *class_name']
  XTextProperty = struct ['unsigned char *value', 'Atom encoding', 'int format',
                          'unsigned long nitems']
  extern 'Display* XOpenDisplay(char*)'
  extern 'int XGetInputFocus(Display*, Window*, int*)'
  extern 'int XGetClassHint(Display*, Window, XClassHint*)'
  extern 'Status XQueryTree(Display*, Window, Window*, Window*, Window**, unsigned int*)'
  extern 'Status XGetWMName(Display*, Window, XTextProperty*)'
  extern 'int Xutf8TextPropertyToTextList(Display*, XTextProperty*, char***, int*)'
  extern 'int XFree(void*)'
  extern 'void XFreeStringList(char**)'
end

ざっくりこんな感じ

(ホントはX11が確保したメモリの解放処理も必要)

class_hint = X11::XClassHint.malloc(Fiddle::RUBY_FREE)
parent = X11::Window.malloc(Fiddle::RUBY_FREE)
children = X11::Pointer.malloc(Fiddle::RUBY_FREE)
_ = ' '*8
display = X11.XOpenDisplay(nil)
w = X11::Window.malloc(Fiddle::RUBY_FREE)
X11.XGetInputFocus(display, w, _)
win = w.window
while win > 0
  class_hint.name = class_hint.class_name = nil
  X11.XGetClassHint(display, win, class_hint)
  break unless class_hint.name.null? && class_hint.class_name.null?
  X11.XQueryTree(display, win, _, parent, children, _)
  win = parent.window
end
prop = X11::XTextProperty.malloc(Fiddle::RUBY_FREE)
text_list = X11::Pointer.malloc(Fiddle::RUBY_FREE)
X11.XGetWMName(display, win, prop)
X11.Xutf8TextPropertyToTextList(display, prop, text_list, _)
p [class_hint.class_name.to_s, text_list.ptr.ptr.to_s.force_encoding('utf-8')]

まとめ

  • /dev/input/event* で入力イベントを読める
  • /dev/input/event* を GRAB すると入力がアプリに渡らなくなる
  • /dev/uinput で仮想入力デバイスを作れる
  • 大きなライブラリの関数をつまみ食いするには Fiddle が便利