🦍

マルチプラットフォームでクリップボードを扱えるDenoモジュールを作った

2022/08/22に公開

初めに

Denoでクリップボードを扱うモジュールdeno-clippyを作ったので、
それの宣伝と学んだことを書いていきます。

https://github.com/skanehira/deno-clippy

ちょうど一年前にこんな記事を書いてありましたが、このライブラリは画像のみでした。
deno-clippyは画像(PNG)とテキスト両方を扱えます。

https://zenn.dev/skanehira/articles/2021-08-22-deno-clipboard-image

使い方

こんな感じで使います。
特に変わったことはなく、シンプルな感じです。

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プラグインシステム)製のプラグインがありますが、これにはクリップボードから画像を読み取ってツイートする機能があります。
こういうところでちょっと出番があったります。

https://github.com/skanehira/denops-twihi.vim

また、DenoでGUIアプリを作ることもできるので、そのアプリでクリップボードを扱いたい場合に使えるかと思います。

https://github.com/webview/webview_deno

仕組み

Denoには動的ライブラリを扱うFFI APIがあります。
FFIを使えば、異なる言語で書かれた関数をDenoから呼び出し、実行結果を受け取ることができます。
つまり、Rustの関数をDenoから呼び出せる、ということですね。
この仕組を利用して、Denoからarboardを使っています。

ただ、残念ながらarboardLinuxでは正常に動作しません。
色々と調査してみたものの、原因がわからずだったので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側に渡したい場合は次の手順を踏む必要があります。

  1. 文字列をUint8Arrayに変換
  2. Rustの関数定義で、引数は第1がポインタ、第2がデータ長とする
    #[no_mangle]
    pub extern "C" fn set_text<'sym>(arg0: *const u8, arg1: usize) {
    
  3. Deno側のFFI定義もそれに合わせる
    "somefunc": { parameters: ["pointer", "usize"] }
    
  4. 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を使っていましたが、今回は残念ながらこのバグで一部のプラットフォームのコンパイルができませんでした。

https://zenn.dev/skanehira/articles/2022-07-12-rust-rjo

根が深そうだったので、おとなしく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でテストを実行するのあたり、macOSWindowsはクリップボードを扱えますが、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