kanejaku.org

Tauri を試す - 2022/07/13

13 Jul 2022

少し前に Tauri という Electron 風ツールキットの 1.0 リリースが出た。WebView を使うので Chromium がくっついてこない。バイナリサイズが小さくて済みそうだ。開発体験はどんな感じかと以前 Electron で作ったツールを Tauri を使って書き直している。

ツールは単純なもので、ドラッグ&ドロップされたファイルの文字コードを変換するだけ。GUI 操作だけで日常作業を手早く済ませたいと要望を受けて作ったもの。単純だけど helloworld よりはやる事がある。手間取ったところいくつかがあるので書いておこうと思う。

まずはドラッグ&ドロップ。Electron だとWeb アプリと同じように drop を listen すれば良い。

const { ipcRenderer } = require("electron");
const dragEl = document.getElementById("drag-target");
dragEl.ondrop = event => {
  const files = event.dataTransfer.files;
  let paths = [];
  for (let i = 0; i < files.length; i++) {
    paths.push(files[i].path);
  }
  ipcRenderer.send("convert", paths);
};

Tauri では独自の API を使わないとドラッグ&ドロップを処理できない。

// dist/main.js
const tauri = window.__TAURI__;
tauri.event.listen("tauri://file-drop", (event) => {
  const paths = event.payload;
  tauri.invoke("convert", paths);
});

onFileDropEvent() を使ってもいい。

次は設定の保存。ウィンドウの位置やサイズ、元ファイルを上書きしたいか、などの情報を保存して次回起動時に復元したい。以下のステップを使って実現している:

順番に見ていく。

設定ファイルのパス

Electron で作ったツールではホストプロセスから app.getPath("userData") を呼んで設定ファイルを保存するディレクトリを取得していた。Tauri は同等の API を公開していない様子。公開しているのかもしれないが自分がざっと見た感じでは見つけられなかった。

ホストプロセスは Rust で書かれたアプリケーションに過ぎないので Tauri が提供する API に縛られる必要はない。適当な crate を使って設定ファイルのパスを決めればいい。依存する crate を減らすために既に Tauri が依存している dirs-next を使う。

// src-tauri/src/main.rs
fn settings_path() -> std::io::Result<Option<PathBuf>> {
    let config_path = match dirs_next::config_dir() {
        Some(path) => path,
        None => return Ok(None),
    };
    let config_path = config_path.join("my-tool");
    fs::create_dir_all(&config_path)?;
    let settings_path = config_path.join("settings.json");
    Ok(Some(settings_path))
}

ウィンドウイベント処理

ウィンドウの位置やサイズの変更はホストプロセス、レンダラプロセスどちらからでも検出できる。

ホストプロセスでウィンドウイベントを検出する場合:

let settings = read_settings(settings_path())?;
let settings = Arc::new(Mutex::new(settings)); // (*) 後述
tauri::Builder::default()
  .on_window_event(move |global_event| match global_event.event() {
    tauri::WindowEvent::Moved(position) => {
      settings.lock().unwrap().window_position = Some(position.clone());
    }
    tauri::WindowEvent::Resized(size) => { /* (omit) */ }
    tauri::WindowEvent::Destroyed => {
      save_settings(settings.lock().unwrap().clone());
    }
    _ => (),
  })
  // ...

on_window_event()Send + Sync + 'static なクロージャを要求する。面倒くさいことは考えたくないので定番の Arc<Mutex<T>>settings を包む。

レンダラプロセスからは "tauri://move" を listen するか onMoved() あたりを使う。

WebView 状態の保存

WebView 上でのイベントはレンダラプロセスでしか拾えない。入力フィールドの値を永続化したい場合はレンダラプロセスからホストプロセスへ状態の変化を通知して、ホストプロセス側でその状態を維持する。 レンダラプロセスから emit() してホストプロセスで listen() する。

// dist/main.js
const el = document.getElementById("my-field");
el.addEventListener("change", _ => {
  tauri.event.emit("my-field-changed", el.value);
});
// src-tauri/src/main.rs
let settings_for_setup: Arc<Mutex<Settings>> = settings.clone();
tauri::Builder::default()
  // ...
  .setup(move |app| {
    let main_window = app.get_window("main").unwrap();
    main_window.listen("my-field-changed", move |event| {
      let my_field = event.payload().unwrap_or("").to_owned();
      settings_for_setup.lock().unwrap().my_field = my_field;
    });
  })
  // ...

起動時の状態の復元

二回目以降の起動ではウィンドウの位置とサイズ、WebView の状態を保存した設定ファイルから復元したい。Electron では BrowserWindow を作るコールバックで位置とサイズを設定し、did-finish-load イベントを受け取ったタイミングでページ内の状態を送っていた。これで期待通り動いていた。

Tarui で同じ挙動をさせるのは手間がかかった。

ウィンドウの復元から。最初は tauri::Builder::on_page_load() で位置とサイズを設定する方法を試した。こうするとウィンドウが初期値の位置とサイズで描画された後にウィンドウが移動してリサイズされてしまう。この挙動は受け入れられない。

次に試したのはアプリを起動する前に WindowConfig を上書きするというもの。

let mut context = tauri::generate_context!();
let mut window_config = &mut context.config_mut().tauri.windows[0];
if let Some(ref window_position) = settings.window_position {
  window_config.x = Some(window_position.x as f64);
  window_config.y = Some(window_position.y as f64);
}

手元の環境では挙動が定まらない。たまに期待通りの位置とサイズで表示されることもあるが、変な位置とサイズで表示されることもある。挙動に一貫性がなく、printf デバッグしても原因がつかめない。

結局今は以下のように実装している:

  1. 起動時にはウィンドウを非表示にして、
  2. tauri::Builder::setup() に渡したクロージャで位置とサイズを復元し、
  3. tauri::Window::show() を呼んでウィンドウを表示する。

次に WebView の状態を復元する。on_page_load() はこの用途でも使えなかった。on_page_load()emit() したイベントを WebView 側で listen() しようと試行錯誤したが上手くいかない。タイミングの問題だろうかと dist/main.js が読み込まれた直後に listen() してもイベントハンドラが呼ばれない。

今の実装では tauri::Builder::manage() を使っている。manage() を使うとアプリケーションに任意のデータ (Tauri では state と言っている) を紐づけることが出来る。以下のように settings をアプリケーションに紐づけて:

// src-tauri/src/main.rs
let shared_settings: Arc<Mutex<Settings>> = settings.clone();
tauri::Builder::default()
  // ...
  .manage(shared_settings)
  .invoke_handler(tauri::genrate_handler![get_my_field, /* ... */])
  // ...

WebView 側で設定を取得する。

// dist/main.js
const my_field = await tauri.invoke("get_my_field");

設定を取得するコマンドは以下のような感じ。

// src-tauri/src/main.rs
#[tauri::command]
fn get_my_field(settings: State<'_, Arc<Mutex<Settings>>>) -> String {
    settings.lock().unwrap().my_field
}

Electron は戸惑うことがほぼなく、作りたいものを素直に書けた印象がある。Tauri はちぐはぐ。挙動、設計、コード、ドキュメントのどれをとっても成熟の差が出ていると思った。

余談: この記事を書いている段階での最新版である 1.0.3 には MacOS でウィンドウのリサイズイベントが検出できないバグがある。安定版を名乗るには致命的な感じがあるが、そのうち直ると思う。