Slide 1

Slide 1 text

Linux用キーリマッパーを作る技術 Linux用キーリマッパーを作る技術 Nagano.rb #9 2022-02-26 とみたまさひろ 1

Slide 2

Slide 2 text

自己紹介 自己紹介 とみたまさひろ MySQL / メール / 文字化け https://twitter.com/tmtms https://blog.tmtms.net https://zenn.dev/tmtms 2

Slide 3

Slide 3 text

Linux用キーリマッパーを作った Linux用キーリマッパーを作った Linux デスクトップ環境を使ってる人向け 3

Slide 4

Slide 4 text

経緯 経緯 11月に転職して人生初 Mac キーマップに慣れない でも Mac のキーマップの方が良さそう (Ctrl+N や Ctrl+P がブラウザに取られない) 仕事以外で使ってる Linux でも Mac と同じにしよう! 4

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

Ctrl-N を ↓ Ctrl-P を ↑ Ctrl-F を → Ctrl-B を ← Alt-[A-Z] を Ctrl-[A-Z] みたいな感じにすれば良さそう 6

Slide 7

Slide 7 text

Ctrl-K は普通は行末まで削除 でも日本語入力時には Ctrl-K はカタカナ変換にしたい できなそう… 🤔 じゃあ自分で作ってみるか 7

Slide 8

Slide 8 text

Rkremap Rkremap https://github.com/tmtm/rkremap 8

Slide 9

Slide 9 text

アプリではなくライブラリ 設定ファイルではなくプログラムを書く必要あり YAML はつらい… ↓ Ruby の DSL もいいかも… ↓ だったら Ruby プログラム書けばいいんじゃね 9

Slide 10

Slide 10 text

実行時に要root権限 実行時に要root権限 ユーザーを input グループに追加すればいいんだけど セキュリティ的にちょっとこわいかも sudo なしでやるには 「Option 2: Run xremap without sudo」 https://github.com/k0kubun/xremap#usage 10

Slide 11

Slide 11 text

例 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 11

Slide 12

Slide 12 text

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 12

Slide 13

Slide 13 text

状態ファイルの有無で分岐 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 ... 13

Slide 14

Slide 14 text

キーロガー的なやつ 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 14

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

キー入力情報 キー入力情報 16

Slide 17

Slide 17 text

/dev/input/event* から24バイト読む Rubyで(要root) 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 }; ev = File.open('/dev/input/event3') raw = ev.sysread(24) sec, usec, type, code, value = raw.unpack('Q!Q!SSl') 17

Slide 18

Slide 18 text

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 # 区切り 18

Slide 19

Slide 19 text

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' 離す 19

Slide 20

Slide 20 text

キーイベントは読めるけどアプリにも渡る GRAB すればアプリに渡さず横取りできる キー入力が効かなくなるので注意! ev.ioctl(1074021776, 1) # EVIOCGRAB 20

Slide 21

Slide 21 text

デバイスファイルの選択 デバイスファイルの選択 21

Slide 22

Slide 22 text

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 22

Slide 23

Slide 23 text

キーボードデバイスかどうか キー A, Z に対応しているか EV_KEY = 0x01 # /usr/include/linux/input-event-codes.h より buf = '' ev.ioctl(2147566880, buf) # EVIOCGBIT(0, 1) buf[0].ord & EV_KEY #=> 1 ならキーボードデバイス 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' に対応 23

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

/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 作成 25

Slide 26

Slide 26 text

作られたデバイスを evtest で見てみる A と Z しか入力できないデバイス % 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) 26

Slide 27

Slide 27 text

キー入力イベントの作成 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')) # 区切り 27

Slide 28

Slide 28 text

毎秒 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 28

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

キー変換処理まとめ キー変換処理まとめ /dev/input/event* を GRAB してキーイベントを読む イベントを加工 /dev/uinput で作成したデバイスに書き込む 30

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

X11 で入力フォーカスがあたってるアプリ名の取得 C の場合(ざっくりと): 1. XGetInputFocus() でフォーカス Window 取得 2. XGetClassHint() で Window のクラス名を取得 3. クラス名が得られたら XGetWMName() でウィンドウタイ トルを得る 4. クラス名が NULL なら XQueryTree() で親 Window を 得て 2 に戻る 33

Slide 34

Slide 34 text

Ruby には良さそうな X11 ライブラリがなさそう X11 の全機能を網羅するのは大変そうだし % ls /usr/share/man/man3 | grep -c '^X.*\.3\.gz$' 1231 34

Slide 35

Slide 35 text

C 拡張を書くのもアレなので Fiddle で libX11 から必要な機能をつまみ食い 35

Slide 36

Slide 36 text

Fiddle 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 36

Slide 37

Slide 37 text

構造体やポインタも扱える 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 #=> マイクロ秒 37

Slide 38

Slide 38 text

必要な関数のみ使えるようにして 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 38

Slide 39

Slide 39 text

ざっくりこんな感じ (ホントは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')] 39

Slide 40

Slide 40 text

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