Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Linux用キーリマッパーを作る技術 / How to make Key Remapper

Linux用キーリマッパーを作る技術 / How to make Key Remapper

とみたまさひろ

February 26, 2022
Tweet

More Decks by とみたまさひろ

Other Decks in Technology

Transcript

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  4. 経緯
    経緯
    11月に転職して人生初 Mac
    キーマップに慣れない
    でも Mac のキーマップの方が良さそう

    (Ctrl+N や Ctrl+P がブラウザに取られない)
    仕事以外で使ってる Linux でも Mac と同じにしよう!
    4

    View full-size slide

  5. 「最強のキーリマッパー xremap」

    便利!
    https://k0kubun.hatenablog.com/entry/xremap
    5

    View full-size slide

  6. Ctrl-N を ↓

    Ctrl-P を ↑

    Ctrl-F を →

    Ctrl-B を ←

    Alt-[A-Z] を Ctrl-[A-Z]
    みたいな感じにすれば良さそう
    6

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  9. アプリではなくライブラリ
    設定ファイルではなくプログラムを書く必要あり
    YAML はつらい…



    Ruby の DSL もいいかも…



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

    View full-size slide

  10. 実行時に要root権限
    実行時に要root権限
    ユーザーを input グループに追加すればいいんだけど

    セキュリティ的にちょっとこわいかも
    sudo なしでやるには



    「Option 2: Run xremap without sudo」
    https://github.com/k0kubun/xremap#usage
    10

    View full-size slide


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

    View full-size slide

  12. 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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  17. /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

    View full-size slide

  18. 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

    View full-size slide

  19. 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

    View full-size slide

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

    20

    View full-size slide

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

    View full-size slide

  22. 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

    View full-size slide

  23. キーボードデバイスかどうか
    キー 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

    View full-size slide

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

    View full-size slide

  25. /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

    View full-size slide

  26. 作られたデバイスを 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

    View full-size slide

  27. キー入力イベントの作成
    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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    1231

    34

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  37. 構造体やポインタも扱える
    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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide