マルチプラットフォームでクリップボードを扱えるDenoモジュールを作った
初めに
Denoでクリップボードを扱うモジュールdeno-clippy
を作ったので、
それの宣伝と学んだことを書いていきます。
ちょうど一年前にこんな記事を書いてありましたが、このライブラリは画像のみでした。
deno-clippy
は画像(PNG)とテキスト両方を扱えます。
使い方
こんな感じで使います。
特に変わったことはなく、シンプルな感じです。
import * as clippy from "https://deno.land/x/clippy/mod.ts";
import { readAll } from "https://deno.land/std@0.152.0/streams/conversion.ts";
// write image to clipboard
const f = await Deno.open("testdata/out.png");
await clippy.write_image(f);
f.close();
// read image from clipboard
const r = await clippy.read_image();
const data = await readAll(r);
await Deno.writeFile(data);
// write text to clipboard
await clippy.write_text("hello clippy");
// read text from clipboard
const text = await clippy.read_text();
console.log(text);
モチベーション
最近Rustを勉強している中で、arboardというライブラリを見つけました。
ドキュメントを読むと外部コマンド依存なしでクリップボードを扱えるとあったので、これをDenoから使えれば理想だった外部依存なしのクリップボードを扱うモジュールが作れるのでは?と思ったのがきっかけでした。
Denoからクリップボードを使いたいことなんてあるのか?と疑問に思う人もいるかも知れません。
実際にdenops-twihi.vimというdenops(Denoで動くVimプラグインシステム)製のプラグインがありますが、これにはクリップボードから画像を読み取ってツイートする機能があります。
こういうところでちょっと出番があったります。
また、DenoでGUIアプリを作ることもできるので、そのアプリでクリップボードを扱いたい場合に使えるかと思います。
仕組み
Denoには動的ライブラリを扱うFFI APIがあります。
FFI
を使えば、異なる言語で書かれた関数をDenoから呼び出し、実行結果を受け取ることができます。
つまり、Rust
の関数をDeno
から呼び出せる、ということですね。
この仕組を利用して、Deno
からarboard
を使っています。
ただ、残念ながらarboard
はLinux
では正常に動作しません。
色々と調査してみたものの、原因がわからずだったのでLinux
の場合はいったんxclip
依存という実装をしました。(悔しい)
もし原因についてご存知の方いましたら教えていただける助かります。
コード生成
FFI APIのリンク先を見てもらえれば分かると思いますが、Deno.dlopen
の第2引数にはRustの関数IF(名前や引数、戻り値の型)を定義して、それを呼び出す様になっています。
const libName = `./libadd.${libSuffix}`;
// Open library and define exported symbols
const dylib = Deno.dlopen(
libName,
{
"add": { parameters: ["isize", "isize"], result: "isize" },
} as const,
);
// Call the symbol `add`
const result = dylib.symbols.add(35, 34); // 69
関数が少なく、また戻り値がシンプルなプリミティブ型なら手で書くのは問題ないですが、
関数の数が増えたり引数と戻り値がプリミティブ型以外(たとえば構造体や文字列など)のものになると手で書くのは一苦労です。
たとえば、文字列を引数としてRust側に渡したい場合は次の手順を踏む必要があります。
- 文字列を
Uint8Array
に変換 -
Rust
の関数定義で、引数は第1がポインタ、第2がデータ長とする#[no_mangle] pub extern "C" fn set_text<'sym>(arg0: *const u8, arg1: usize) {
-
Deno
側のFFI
定義もそれに合わせる"somefunc": { parameters: ["pointer", "usize"] }
-
Uint8Array
のポインタと長さを関数呼び出し時に渡すlib.symbols.set_text(a0_buf, a0_buf.byteLength)
戻り値がプリミティブ型以外の場合も同様にポインタを扱う必要があるので、メモリレイアウトを意識する必要があります。
function readPointer(v: any): Uint8Array {
const ptr = new Deno.UnsafePointerView(v as bigint)
const lengthBe = new Uint8Array(4)
const view = new DataView(lengthBe.buffer)
ptr.copyInto(lengthBe, 0)
const buf = new Uint8Array(view.getUint32(0))
ptr.copyInto(buf, 4)
return buf
}
このようにポインタを直接触る必要があるので、不慣れな自分としてできれば避けたい部分です。
ありがたいことに、Denoが公式でdeno_bindgenというコードジェネレーターを提供してくれています。しかもRustに対応しています。
これを使えばRust
の関数に#[deno_bindgen]
マクロを記述した状態でdeno_bindgen
コマンドを実行するだけで、コードを生成してくれます。
#[deno_bindgen]
pub fn get_text() -> ClipboardResult {
match inner_get_text() {
Ok(data) => ClipboardResult::Ok { data: Some(data) },
Err(err) => ClipboardResult::Error {
error: err.to_string(),
},
}
}
生成されたDeno側のコード(一部)はこんな感じになっています。
export function get_text() {
let rawResult = _lib.symbols.get_text()
const result = readPointer(rawResult)
return JSON.parse(decode(result)) as ClipboardResult
}
deno_bindgen
を使えば、利用する側はポインタのことを気にすることなく、関数を呼び出せる様になるります。
コンパイル
今回はマルチプラットフォーム対応をするということで、
各OSで動く動的ライブラリをコンパイルする必要があります。
以前書いたこちらの記事では、cargo-zigbuild
を使っていましたが、今回は残念ながらこのバグで一部のプラットフォームのコンパイルができませんでした。
根が深そうだったので、おとなしくGitHub Actions
を使って各プラットフォームでビルドしたものをリリースにアップロードしました。
ただ、ARM
のホストがないので、そこはcrossを使ってビルドしました。
- name: Build aarch64
if: ${{ matrix.target == 'aarch64-apple-darwin' }}
run: |
cargo install cross --git https://github.com/cross-rs/cross
rustup target add ${{ matrix.target }}
cross build --release --target ${{ matrix.target }}
mv target/${{ matrix.target }}/release/libdeno_clippy.dylib target/${{ matrix.target }}/release/${{ matrix.lib }}
- name: Build
if: ${{ matrix.target != 'aarch64-apple-darwin' }}
run: |
rustup target add ${{ matrix.target }}
cargo build --release --target ${{ matrix.target }}
- name: Create release
uses: ncipollo/release-action@v1
with:
omitBody: true
allowUpdates: true
artifacts: 'target/${{ matrix.target }}/release/${{ matrix.lib }}'
token: ${{ secrets.GITHUB_TOKEN }}
CIの整備
deno-clippy
はテストを書いています。
CIでテストを実行するのあたり、macOS
とWindows
はクリップボードを扱えますが、Linux
はヘッドレスのためクリップボードが扱えませんでした。
色々と調べたら、Xvfbという仮想Xサーバを使用することで、
ヘッドレスなLinux
でもテストできることがわかったのでそれをCIに組み込んだ結果うまく動きました。
Xサーバを必要とするようなテストをCIで回せるようになるので、今後も重宝していきたいですね。
- name: Install xvfb
if: runner.os == 'Linux'
run: |
sudo apt update
sudo apt install xvfb xclip
...
- name: Test in macOS and Windows
if: runner.os != 'Linux'
run: |
make test
- name: Test in Linux
if: runner.os == 'Linux'
run: |
# NOTE: Currently FFI tests do not work properly on Linux
# See #2
xvfb-run make deno-test
リリース
FFI
を使ったモジュールのリリースのやり方は少し特殊です。
モジュールを実行するには共有ライブラリが必要なので、これを指定したURLからダウンロードします。
deno_bindgen
を使えば、ダウンロード先を--release
で指定することできます。
ダウンロード処理は生成したコードに含まれています。
たとえばv0.1.0
をリリースする場合、次のように実行してコードを生成する必要があります。
deno_bindgen --release=https://github.com/skanehira/deno-clippy/releases/download/v0.1.0/
こうすることでモジュールをはじめて実行すると、
指定したURLから必要な共有ライブラリをダウンロードしてキャッシュします。
生成したコードのURLの部分が変わるので、
これをコミット&プッシュしたあとにはじめてリリースタグを打って、
共有ライブラリをGitHub Actionsでビルドしてリリースにアップロードするという流れになります。
最後に
本当はもっとあれこれ試行錯誤しましたが、色々書くとまとまりがないので、コンパクトにまとめました。
なかなか大変でしたが、とても勉強になりました。
気づいたらRust
の勉強の割合があんまりなくて目的から大分離れてしまったんですが、
色々と知見を得られたのでヨシです。
Discussion