Rust+uinputでキー入力カスタマイズ ついでにdebパッケージ化する

f:id:apt-k-ueno:20211201101016j:plain

aptpod Advent Calendar 2021の3日目を担当しますOTチームの大久保です。

今年はRustのエッジ製品への適用がはじまり、RustでLinuxのシステムコールを呼ぶような処理を実装するような場面が増えました。今回はその一例として、Linux上でキー入力カスタマイズをするコードをRustで実装してみます。ついでに、debパッケージにしてUbuntuにインストール、systemdのサービスとして立ち上げるまで行います。

uinputとは

uinputは仮想的な入力デバイスを作成するためにLinuxが提供する機能です。/dev/uinputにイベントを書き出せば、あたかも実体のあるデバイスからのイベントが起きたように振る舞います。キーボードのデバイスファイルからイベントを読み出し、それを変換して/dev/uinputに書き出せば、キー入力のカスタマイズが可能になります。本来ならC言語で書くようなものなのですが、これをRustで書くのが今回の趣旨になります。

1.7. uinput module — The Linux Kernel documentation

筆者は以前Shiftキーの押しすぎで小指を痛めたことがあり、それ以来変換キーをShiftに割り当てる設定をX Window Systemの仕組みを使って行っていました。しかしこの方法はキーボードの抜き差しがあると機能しなくなったり、また今後XからWaylandに移行すると使えなくなることが予想されました。そのため、今回は変換キーの入力をShiftに変換するよう実装します。そのためこのRustのプロジェクト名はhenkan-shiftとします。

bindgenでCのヘッダファイルを読み込む

uinputはLinuxのカーネルモジュールなので、その定義はlinux/uinput.hヘッダファイルに書かれています。これをRustから利用できるようにするためには、bindgenを使います。 bindgenは、CやC++のライブラリを、Rustから呼び出すためのコードを生成してくれるものです。

次のようなwrapper.hを作成します。

#include <linux/uinput.h>

build.rsを用意します。

use std::env;
use std::path::PathBuf;

fn main() {
    println!("cargo:rerun-if-changed=wrapper.h");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .ctypes_prefix("libc")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        .generate()
        .expect("Unable to generate bindings");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

ここで生成されたbindings.rsを読み込むためsrc/wrapper.rsを用意します。

#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

ioctlを使用可能にする

bindgenを使えば大方の定義はインポートできますが、現状は関数形式マクロをうまく取り扱ってくれません。そして、マクロを多用するioctlに関する定義をインポートすることもできません。

Rustからioctlシステムコールを利用するには、nixクレートが提供するマクロを使用します。マクロを使用すると、Rustから普通に呼び出せる関数を定義することになります。

今回は以下のように使います。コメントは対応するヘッダファイル内の定義になります。

// #define UI_DEV_CREATE           _IO(UINPUT_IOCTL_BASE, 1)
nix::ioctl_none!(ui_dev_create, UINPUT_IOCTL_BASE, 1);
// #define UI_DEV_DESTROY          _IO(UINPUT_IOCTL_BASE, 2)
nix::ioctl_none!(ui_dev_destroy, UINPUT_IOCTL_BASE, 2);
// #define UI_DEV_SETUP _IOW(UINPUT_IOCTL_BASE, 3, struct uinput_setup)
nix::ioctl_write_ptr!(ui_dev_setup, UINPUT_IOCTL_BASE, 3, uinput_setup);
// #define UI_SET_EVBIT            _IOW(UINPUT_IOCTL_BASE, 100, int)
nix::ioctl_write_int!(ui_set_evbit, UINPUT_IOCTL_BASE, 100);
// #define UI_SET_KEYBIT           _IOW(UINPUT_IOCTL_BASE, 101, int)
nix::ioctl_write_int!(ui_set_keybit, UINPUT_IOCTL_BASE, 101);
// #define EVIOCGRAB               _IOW('E', 0x90, int)
nix::ioctl_write_int!(eviocgrab, 'E', 0x90);

Cの定義からの変換は比較的単純なので、build.rsに自動生成させることもできます。その場合は以下のように書くことになるでしょうか。

use once_cell::sync::Lazy;
use regex::Regex;
use std::env;
use std::fs::OpenOptions;
use std::io::prelude::*;
use std::path::{Path, PathBuf};

fn main() {
    println!("cargo:rerun-if-changed=wrapper.h");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    let callback =
        Callbacks::new(out_path.join("ioctl_generated.rs")).expect("couldn't create callback");

    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .use_core()
        .ctypes_prefix("libc")
        .parse_callbacks(Box::new(callback));

    // Set header include paths
    let bindings = bindings.generate().expect("unable to generate bindings");

    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("couldn't write bindings");
}

static RE_IOWR: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"#define\s+(\w+)\s+(_IO[WR]*)\(([^,)]+),\s*([^,)]+),?\s*([^,)]*)\)").unwrap()
});

#[derive(Debug)]
struct Callbacks(PathBuf);

impl Callbacks {
    fn new<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
        let _ = std::fs::File::create(&path)?;
        Ok(Callbacks(path.as_ref().to_owned()))
    }

    fn generate(&self, filename: &str) -> std::io::Result<()> {
        let input_file = std::fs::read_to_string(filename)?;

        let mut output_file = OpenOptions::new().create(true).append(true).open(&self.0)?;

        for caps in RE_IOWR.captures_iter(&input_file) {
            let define_name = caps[1].to_ascii_lowercase();
            let iorw = &caps[2];
            let io_type = &caps[3];
            let number = &caps[4];

            let (macro_name, ty) = match iorw {
                "_IO" => ("ioctl_none", None),
                "_IOR" => ("ioctl_read", Some(&caps[5])),
                "_IOW" => {
                    let ty = &caps[5];
                    let macro_name = if ty == "long" {
                        "ioctl_write_int"
                    } else {
                        "ioctl_write_ptr"
                    };
                    (macro_name, Some(ty))
                }
                "_IOWR" => ("ioctl_readwrite", Some(&caps[5])),
                _ => continue,
            };

            writeln!(output_file, "\n// {}", &caps[0])?;
            match ty {
                Some(ty) if macro_name != "ioctl_write_int" => {
                    let ty = ty.trim_start_matches("struct ");
                    let ty = if ty == "int" { "libc::c_int" } else { ty };
                    writeln!(
                        output_file,
                        "nix::{}!({}, {}, {}, {});",
                        macro_name, define_name, io_type, number, ty
                    )?;
                }
                _ => {
                    writeln!(
                        output_file,
                        "nix::{}!({}, {}, {});",
                        macro_name, define_name, io_type, number
                    )?;
                }
            }
        }

        Ok(())
    }
}

impl bindgen::callbacks::ParseCallbacks for Callbacks {
    fn include_file(&self, filename: &str) {
        println!("cargo:rerun-if-changed={}", filename);
        self.generate(filename).expect("failed");
    }
}

bindgenでParseCallbacksというトレイトを実装してやると、ヘッダファイル中に現れた記述に応じて呼び出されるコールバック関数を実装できるのでそれを使用します。関数形式マクロが用いられる箇所に対するコールバックは現状無いので、ioctlに関する定義を正規表現で見つけてnixのマクロに変換して書き出します。Cの型からRustの型への変換はこの例では雑にやっています。自動生成するためのコード自体割と長くなるので、今回のuinputの例では手書きで実装します。

本体を実装する

src/main.rsは以下のように記述します。

pub mod wrapper;

use anyhow::Result;
use libc::{c_char, c_ushort};
use nix::fcntl::{open, OFlag};
use nix::unistd::{close, read, write};
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use std::os::unix::io::RawFd;
use std::path::PathBuf;
use std::thread::sleep;
use std::time::Duration;

// bindgenで生成した定義を使用する
use wrapper::{
    input_event, uinput_setup, BUS_USB, EV_KEY, KEY_ESC, KEY_HENKAN, KEY_INSERT, KEY_LEFTSHIFT,
    KEY_MICMUTE, UINPUT_IOCTL_BASE,
};

const DEVICE_NAME: &[u8] = b"henkan-shift-kbd";
const EVENT_FILE_DIR: &str = "/dev/input/by-id";
const INPUT_EVENT_SIZE: usize = std::mem::size_of::<input_event>();

// #define UI_DEV_CREATE           _IO(UINPUT_IOCTL_BASE, 1)
nix::ioctl_none!(ui_dev_create, UINPUT_IOCTL_BASE, 1);
// #define UI_DEV_DESTROY          _IO(UINPUT_IOCTL_BASE, 2)
nix::ioctl_none!(ui_dev_destroy, UINPUT_IOCTL_BASE, 2);
// #define UI_DEV_SETUP _IOW(UINPUT_IOCTL_BASE, 3, struct uinput_setup)
nix::ioctl_write_ptr!(ui_dev_setup, UINPUT_IOCTL_BASE, 3, uinput_setup);
// #define UI_SET_EVBIT            _IOW(UINPUT_IOCTL_BASE, 100, int)
nix::ioctl_write_int!(ui_set_evbit, UINPUT_IOCTL_BASE, 100);
// #define UI_SET_KEYBIT           _IOW(UINPUT_IOCTL_BASE, 101, int)
nix::ioctl_write_int!(ui_set_keybit, UINPUT_IOCTL_BASE, 101);
// #define EVIOCGRAB               _IOW('E', 0x90, int)
nix::ioctl_write_int!(eviocgrab, 'E', 0x90);

// /dev/input/by-id/*Keyboard-event-kbd となるデバイスファイルを検知する
pub fn detect_device(postfix: &str) -> Result<PathBuf> {
    for entry in std::fs::read_dir(EVENT_FILE_DIR)? {
        let entry = entry?;
        let path = entry.path();
        if path.to_string_lossy().ends_with(postfix) {
            return Ok(path);
        }
    }

    let (tx, rx) = std::sync::mpsc::channel();
    let mut watcher = watcher(tx, Duration::from_millis(100))?;
    watcher.watch(EVENT_FILE_DIR, RecursiveMode::NonRecursive)?;

    loop {
        match rx.recv() {
            Ok(DebouncedEvent::Create(path)) if path.to_string_lossy().ends_with(postfix) => {
                return Ok(path);
            }
            Err(e) => {
                return Err(e.into());
            }
            _ => (),
        }
    }
}

// /dev/uinput に出力するためのルーチン
pub struct Emitter {
    fd: RawFd,
}

impl Emitter {
    fn new() -> Result<Self> {
        // /dev/uinputを開いていろいろセットアップ
        let fd = open(
            "/dev/uinput",
            OFlag::O_WRONLY | OFlag::O_NONBLOCK,
            nix::sys::stat::Mode::empty(),
        )?;
        unsafe {
            // /dev/uinputを利用可能にするためのioctlの呼び出し
            ui_set_evbit(fd, EV_KEY.into())?;
            for key in KEY_ESC..=KEY_MICMUTE {
                ui_set_keybit(fd, key.into())?;
            }
            sleep(Duration::from_secs(1));
            let mut usetup: uinput_setup = std::mem::zeroed();
            usetup.id.bustype = BUS_USB as c_ushort;
            usetup.id.vendor = 0x1234;
            usetup.id.product = 0x5678;
            for (i, c) in DEVICE_NAME.iter().enumerate() {
                usetup.name[i] = *c as c_char;
            }
            ui_dev_setup(fd, &usetup)?;
            ui_dev_create(fd)?;
        }
        Ok(Emitter { fd })
    }

    fn emit(&self, event: input_event) -> Result<()> {
        let event: [u8; INPUT_EVENT_SIZE] = unsafe { std::mem::transmute(event) };
        write(self.fd, &event)?;
        Ok(())
    }
}

impl Drop for Emitter {
    fn drop(&mut self) {
        unsafe {
            let _ = ui_dev_destroy(self.fd);
        }
        let _ = close(self.fd);
    }
}

fn main() -> Result<()> {
    env_logger::builder()
        .filter_level(log::LevelFilter::Info)
        .init();
    let emitter = Emitter::new()?;

    'detect_loop: loop {
        let kbd_event_file = loop {
            match detect_device("Keyboard-event-kbd") {
                Ok(kbd_event_file) => {
                    break kbd_event_file;
                }
                Err(e) => {
                    log::warn!("Keyboard detection failed.\n{}\nretry..", e);
                    sleep(Duration::from_secs(1));
                }
            }
        };
        log::info!("detect keyboard {}", kbd_event_file.display());

        let fd = open(
            &kbd_event_file,
            OFlag::O_RDONLY,
            nix::sys::stat::Mode::empty(),
        )?;

        unsafe {
            eviocgrab(fd, 1)?;
        }

        loop {
            // デバイスファイルからイベント読み込み
            let mut buf = [0u8; INPUT_EVENT_SIZE];
            match read(fd, &mut buf) {
                Ok(INPUT_EVENT_SIZE) => (),
                Err(nix::Error::ENODEV) => {
                    log::info!("No device file {}", kbd_event_file.display());
                    sleep(Duration::from_secs(1));
                    continue 'detect_loop;
                }
                Err(e) => {
                    return Err(e.into());
                }
                Ok(len) => {
                    log::warn!("invalid read len {}", len);
                    continue;
                }
            }

            let mut event: input_event = unsafe { std::mem::transmute(buf) };

            // 変換キーについてのイベントをShiftキーのものに変換する
            if event.type_ == EV_KEY as c_ushort && event.code == KEY_HENKAN as c_ushort {
                event.code = KEY_LEFTSHIFT as c_ushort;
            }

            // ついでによく押し間違えるInsertキーを無効化しておく
            if event.type_ == EV_KEY as c_ushort && event.code == KEY_INSERT as c_ushort {
                event.value = 0;
            }

            // /dev/uinputへの出力
            emitter.emit(event)?;
        }
    }
}

ここではあまり細部まで解説しませんが、やっていることは/dev/input/by-id以下に追加されたキーボードを検知して、そこから読み取ったイベントを書き換えて/dev/uinputに書き出しているだけです。このread/writeも、nixクレートが提供する関数を用いて行っています。

deb/systemd service化する

前項までのコードを動かす際には、デバイスファイルを読み書きするためにroot権限で実行しなければなりません。また普段使いのために、PCの起動のたびに自動で立ち上がるようにしておきたいです。 そこで、systemd serviceでのデーモン化と、これを含めたdebパッケージを作成します。

Rustのプロジェクトをdebパッケージにするには、cargo-debというツールを使います。以下のようにインストールします。

cargo install cargo-deb

Cargo.tomlに以下のように追記します。

[package.metadata.deb]
depends = "$auto"
section = "utility"
priority = "optional"
maintainer-scripts = "debian/"
systemd-units = { enable = false }

dependsは生成されるdebパッケージが依存するであろう他パッケージを記述します。$autoとしておくことで勝手に判定してくれますが、依存するパッケージやバージョンを厳密に指定したい場合は自分で記述することになります。

また、systemd-unitsという設定項目があることからも分かるように、systemd用の設定もcargo-debが面倒を見てくれます。systemdのunitについて設定するには、debian/serviceというファイルに記述します。

[Unit]
Description=Convert henkan key to shift key.
StartLimitIntervalSec=5
StartLimitBurst=1000

[Service]
ExecStart=/usr/bin/henkan-shift
Restart=always

[Install]
WantedBy=graphical.target

今回はプロジェクト名をhenkan-shiftにしたので、実行ファイルの名前も同様になっています。そのため、ExecStart/usr/bin/henkan-shiftを指定します。

以上の用意ができたら以下のコマンドでビルドします。

cargo deb

target/debian以下にdebパッケージが生成されるので、後はこれをインストールするだけです。

sudo dpkg -i target/debian/henkan-shift_0.1.0_amd64.deb

後は普通のsystemdサービスとして使えます。

sudo systemctl enable henkan-shift.service
sudo systemctl start henkan-shift.service
sudo systemctl status henkan-shift.service

あとはこのサービスが立ち上がっている状態で、キー入力が想定通りに機能するか確かめるだけです。

まとめ

今回は、uinputによるキー入力カスタマイズを行うコードを実装しました。これまでCで実装していたような、Linuxのデバイスファイルの読み書きやioctlの呼び出しもRustから行うことができます。さらに、cargo-debを使えば、比較的簡単な設定でdebパッケージ化、systemdサービス化することができます。

最近はRustまわりのライブラリやツールが充実してきたこともあって、Cで書いていた箇所をRustで書くことの利点が増えてきていると感じます。これからも社内で採用箇所を増やしていきたいと思っています。