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で書くことの利点が増えてきていると感じます。これからも社内で採用箇所を増やしていきたいと思っています。