あずんひの日

あずんひの色々を書き留めるブログ

TauriからWebViewだけ引っこ抜いて使う

この記事はRust Advent Calendar 2022 3日目の記事です。

今年6月に「小さいElectron」であるTauriが正式にリリースされ注目されました。 ただこのTauri、Electron比では小さいもののRustプログラムとしてみたらやはり大く、後述しますがサンプルでも7MB超もあります。 というわけで、今回はTauriのWebView部分だけを引っこ抜いてさらに小さく(1~2MB程度)使う方法を紹介します。

なおこの記事の動作確認はWindows 10とLinux(WSL 2上のArch Linux)で行っており、その他のOS(特にmacOS)では動作しない可能性があることに注意してください。

TauriのWebView部分

TauriのWebView部分はWRYというクレートで構成されています。 このWRYはOSのWebView(WindowsWebView2LinuxWebKitGTKmacOSWKWebView)のラッパーに加え、 ウィンドウを管理する機能も提供しています。 つまり、最低限のアプリであれば依存クレートはWRYだけで作れます。

なおWRYのバージョンは執筆時点では正式リリース前の0.22であり、今後もAPIの破壊的変更が予想されます。 記事中のコードは最新バージョンでは動かない可能性があるため注意してください。

またWRYは執筆時点で実験的ながらもAndroidiOSをサポートしており、スマートフォンでも動作させられるようです。

WRYは普通のクレートなのでcargo add wryで使うことが出来ます。 ただし、Windows 10以下ではWebView2 Runtime(Evergreen Bootstrapperで良い)が、 Linuxではディストロごとにwebkit2-gtkなどのインストールが必要です。 詳細はWRYのREADMEを参照してください。

名前の由来

WRYというのは「Webview Rendering Library」の略ですが、 これはジョジョの奇妙な冒険DIOの有名なセリフ「WRYYYYYYYYY」から取っているようでREADMEにはジョジョ3部終盤の名(?)シーン 「ロードローラーだッ!!」のGIFが使われています。どうでもいいですね。
READMEのGIF動画
READMEのGIF動画

取り敢えず動かしてみる

サンプルコードはこんな感じです。 簡単に言えばウィンドウを作り、WebViewを作ってイベントループを開始する、という流れです。 起動時点ではTauriのWebサイト(https://tauri.app/)が表示されます。 ナビゲーションは特に禁止などしていないのでページ遷移や新しいウィンドウの表示ができます (OSにより新規ウィンドウは反応しないこともあります)。

// [dependencies]
// wry = "0.22.0"

use wry::{
  application::{
    event::{Event, StartCause, WindowEvent},
    event_loop::{ControlFlow, EventLoop},
    window::WindowBuilder,
  },
  webview::WebViewBuilder,
};

// 戻り値は必要ならanyhow::Resultにしても良さそう
fn main() -> wry::Result<()> {
  let event_loop = EventLoop::new();
  let window = WindowBuilder::new()
    // ウィンドウタイトル
    .with_title("はろー")
    .build(&event_loop)?;
  let _webview = WebViewBuilder::new(window)?
    // 起動時に表示するURL
    .with_url("https://tauri.app/")?
    .build()?;

  // イベントループを開始。実行中のRust側の処理は基本この中に書く
  event_loop.run(move |event, _, control_flow| {
    // イベントがないときは待機することを示す。
    // ゲームなどアイドル時に色々やりたいことがあるアプリではControlFlow::Pollにし、Event::NewEvents(StartCause::Poll)で処理をする
    *control_flow = ControlFlow::Wait;

    match event {
      // 起動時に発生するイベント
      Event::NewEvents(StartCause::Init) => println!("起動完了"),
      // 終了が要求されたとき(閉じるボタンが押されたとか)に発生するイベント
      Event::WindowEvent {
        event: WindowEvent::CloseRequested,
        ..
      } => *control_flow = ControlFlow::Exit, // アプリを終了させる。ExitWithCode(n)で終了ステータスを設定できる
      _ => {}
    }
  });
}

これをArch LinuxのRust 1.64でビルドすると、デバッグビルドでは208MB、strip済みリリースビルドでは1.4MBとなりました。 cargo create-tauri-appしてそのままビルドしたものと比較すると下記表のようになります。 もちろんWRYのサンプルではRustとの連携処理等が入っていないので参考値に過ぎませんが、それでもかなりの差があることが分かります。

ライブラリ デバッグビルド(MB) strip済みリリースビルド(MB)
WRY 208MB 1.4MB
Tauri 376MB 7.1MB

Webサイトではなく自作HTMLを表示する

サンプルコードでは特定のWebサイトを表示するアプリが作れましたが、 このままではただのしょうもないブラウザ未満のツールなので、まずは自作のHTMLを表示できるようにします。

WebView生成時、URLの代わりにHTMLを指定すれば自作HTMLを表示できます。 ついでにアプリとしては都合の悪いページ遷移と新規ウィンドウを禁止しておきます。

// 全文が後ろにあります
// ...

let _webview = WebViewBuilder::new(window)?
    // ページ遷移を基本禁止
    .with_navigation_handler(|url| {
        // HTML指定時も遷移判定は行われる
        // URL形式はOSによって違うので要確認
        url.starts_with("data:") || url.starts_with("http://localhost/")
    })
    // 新規ウィンドウを全部禁止
    .with_new_window_req_handler(|_url| false)
    // HTMLを指定して表示する
    .with_html("<html><body><h1>It works!</h1></body></html>")?
    .build()?;

全文

// [dependencies]
// wry = "0.22.0"

use wry::{
    application::{
        event::{Event, StartCause, WindowEvent},
        event_loop::{ControlFlow, EventLoop},
        window::WindowBuilder,
    },
    webview::WebViewBuilder,
};

fn main() -> wry::Result<()> {
    let event_loop = EventLoop::new();
    let window = WindowBuilder::new()
        .with_title("はろー")
        .build(&event_loop)?;
    let _webview = WebViewBuilder::new(window)?
        // ページ遷移を基本禁止
        .with_navigation_handler(|url| {
            // HTML指定時も遷移判定は行われる
            // URL形式はOSによって違うので要確認
            url.starts_with("data:") || url.starts_with("http://localhost/")
        })
        // 新規ウィンドウを全部禁止
        .with_new_window_req_handler(|_url| false)
        // HTMLを指定して表示する
        .with_html("<html><body><h1>It works!</h1></body></html>")?
        .build()?;

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Wait;

        match event {
            Event::NewEvents(StartCause::Init) => println!("起動完了"),
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => *control_flow = ControlFlow::Exit,
            _ => {}
        }
    });
}

WRYにHTMLを指定したところ
WRYにHTMLを指定したところ

ただ、これではHTMLからCSSやJSにリンクができずみすぼらしい見た目にしかならないのでなんとかしましょう。 それをするにはカスタムプロトコルを使い、WebViewからRustにリソースを問い合わせられるようにします。 そして、include_dirクレートを使いディレクトリごとバイナリに埋め込み、その内容をカスタムプロトコルから返すようにします。 またContent-Typeがないと文字化けするOSがあるのでmime_guessクレートを使いファイルの種類を判別しています。

なお、このコードではCargo.tomlと同じディレクトリにviewディレクトリとview/index.htmlファイルが必要です。

(こんなイメージ)
┣ src
┃ ┗ main.rs
┣ view
┃ ┣ index.html
┃ ┗ (他にCSSなど必要なもの)
┗ Cargo.toml
// 全文が後ろにあります
// ...

// Cargo.tomlと同じディレクトリのviewディレクトリを埋め込む
static VIEW: Dir = include_dir!("$CARGO_MANIFEST_DIR/view");

// カスタムプロトコルへの要求を処理する関数
fn custom_protocol_handler(req: &Request<Vec<u8>>) -> wry::Result<Response<Vec<u8>>> {
    // ファイルを検索
    let path = req.uri().path().trim_end_matches("/");
    let entry = if path.is_empty() {
        // ルートディレクトリ
        Some(DirEntry::Dir(VIEW.clone()))
    } else {
        // 先頭のスラッシュを省いて検索
        VIEW.get_entry(&path[1..]).cloned()
    };
    let file = match entry {
        Some(DirEntry::Dir(dir)) => dir.get_file("index.html").cloned(),
        Some(DirEntry::File(file)) => Some(file),
        None => None,
    };

    match file {
        // ファイルが見つかればそれを返す
        Some(file) => {
            // Content-Typeがないと文字化けするのでファイル名から推測
            // 不明な場合はtext/plain
            let mime = mime_guess::from_path(file.path()).first_or_text_plain();

            Response::builder()
                .header("Content-Type", mime.as_ref())
                .body(file.contents().to_vec())
        }
        // 見つからなければ404
        _ => {
            eprintln!("not found: {}", path);
            Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body(Vec::new())
        }
    }
    .map_err(Into::into)
}

fn main() {
    // ...

    let _webview = WebViewBuilder::new(window)?
        // カスタムプロトコルでアプリ限定のURLを作れる
        .with_custom_protocol("wry".to_string(), move |req| custom_protocol_handler(req))
        // ページ遷移はカスタムプロトコルだけ許可
        .with_navigation_handler(|url| {
            // URL形式はOSによって違うので要確認
            url.starts_with("https://wry.localhost/") || url.starts_with("wry://localhost/")
        })
        // 新規ウィンドウを全部禁止
        .with_new_window_req_handler(|_url| false)
        // HTMLの代わりにカスタムプロトコルによるURLを指定する
        .with_url("wry://localhost/")?
        .build()?;

    // ...
}

全文

src/main.rs

// [dependencies]
// include_dir = "0.7.3"
// mime_guess = "2.0.4"
// wry = "0.22.0"

use include_dir::{include_dir, Dir, DirEntry};
use wry::{
    application::{
        event::{Event, StartCause, WindowEvent},
        event_loop::{ControlFlow, EventLoop},
        window::WindowBuilder,
    },
    http::{Request, Response, StatusCode},
    webview::WebViewBuilder,
};

// Cargo.tomlと同じディレクトリのviewディレクトリを埋め込む
static VIEW: Dir = include_dir!("$CARGO_MANIFEST_DIR/view");

// カスタムプロトコルへの要求を処理する関数
fn custom_protocol_handler(req: &Request<Vec<u8>>) -> wry::Result<Response<Vec<u8>>> {
    // ファイルを検索
    let path = req.uri().path().trim_end_matches("/");
    let entry = if path.is_empty() {
        // ルートディレクトリ
        Some(DirEntry::Dir(VIEW.clone()))
    } else {
        // 先頭のスラッシュを省いて検索
        VIEW.get_entry(&path[1..]).cloned()
    };
    let file = match entry {
        Some(DirEntry::Dir(dir)) => dir.get_file("index.html").cloned(),
        Some(DirEntry::File(file)) => Some(file),
        None => None,
    };

    match file {
        // ファイルが見つかればそれを返す
        Some(file) => {
            // Content-Typeがないと文字化けするのでファイル名から推測
            // 不明な場合はtext/plain
            let mime = mime_guess::from_path(file.path()).first_or_text_plain();

            Response::builder()
                .header("Content-Type", mime.as_ref())
                .body(file.contents().to_vec())
        }
        // 見つからなければ404
        _ => {
            eprintln!("not found: {}", path);
            Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body(Vec::new())
        }
    }
    .map_err(Into::into)
}

fn main() -> wry::Result<()> {
    let event_loop = EventLoop::new();
    let window = WindowBuilder::new()
        .with_title("はろー")
        .build(&event_loop)?;
    let _webview = WebViewBuilder::new(window)?
        // カスタムプロトコルでアプリ限定のURLを作れる
        .with_custom_protocol("wry".to_string(), move |req| custom_protocol_handler(req))
        // ページ遷移はカスタムプロトコルだけ許可
        .with_navigation_handler(|url| {
            // URL形式はOSによって違うので要確認
            url.starts_with("https://wry.localhost/") || url.starts_with("wry://localhost/")
        })
        // 新規ウィンドウを全部禁止
        .with_new_window_req_handler(|_url| false)
        // HTMLの代わりにカスタムプロトコルによるURLを指定する
        .with_url("wry://localhost/")?
        .build()?;

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Wait;

        match event {
            Event::NewEvents(StartCause::Init) => println!("起動完了"),
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => *control_flow = ControlFlow::Exit,
            _ => {}
        }
    });
}

view/index.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8"><!-- 文字化け回避に必須 -->
    <link rel="stylesheet" href="/style.css">
    <script type="module" src="/app.js" defer></script>
  </head>
  <body>
    <input id="input">
    <button id="button">ボタン</button>
  </body>
</html>

view/style.css

html, body {
  height: 100%;
  margin: 0;
}

body {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

view/app.js

window.addEventListener("DOMContentLoaded", () => {
  const input = document.getElementById("input");
  const button = document.getElementById("button");
  button.addEventListener("click", () => alert(input.value));
});

カスタムプロトコルでHTMLなどを配信したところ
カスタムプロトコルでHTMLなどを配信したところ

カスタムプロトコルの制約

カスタムプロトコルは一見Webサーバーと同じように実装できそうな雰囲気ですが、 実のところ即座に値を返さないとGUIをフリーズさせる(結果としてストリーミングもできない)、 Linuxではそもそもデータを受信することすらできないといった厳しい制約があります。 要は既に用意されたデータを返すくらいしかできることがないのです。 いやキツくね?

OS(WebView)側のAPIを簡単に眺めた感じではストリーミングはできなさそうなものの、レスポンスを非同期に返すことはできそうです。 この辺りはWRYの改善に期待したいところです。

RustとJSを連携させ・・・たかった

ここまででHTMLなどを埋め込んでOSのWebViewで表示するアプリが作れるようになりましたが、 このままではただのしょうもないPWA未満のアプリなので、次はRustと連携できるようにしましょう。

連携と言えば双方向通信ですが、結論から言えば現在のWRYの機能を使ってRustとJavaScriptの双方向通信を直接行うことは不可能です。 前述した通りカスタムプロトコルは制約が厳しいですし、それ以外の後述するAPIも一方向の通信しかできず、双方向通信は不可能です。 TauriにはJavaScriptからRustの関数を呼び出して戻り値を受け取れるのAPIがありますが、 これは一方向通信を組み合わせて頑張って双方向通信風に仕上げているものです。 しかも、Tauriも結局のところRustからJavaScriptを呼び出して戻り値を受け取ることはできません。 かなし~

また一方向通信のAPIもやり取りできるのは文字列のみであり、バイナリデータをやり取りしようと思うと効率が悪くなります。 カスタムプロトコルではバイナリデータをそのままやり取りできるものの、前述した制約がある以上gRPCなどのRPCを実装することはできません。 将来WRYの仕様が改善されてOS(WebView)の機能を活用できるようになればロングポーリングくらいは実装できそうなのでそれに期待です。

と言うわけで、現状は後述する一方向通信をなんとか組み合わせてRPCチックな仕組みを構築する必要があります (Rust側にデータがあることをevaluate_scriptJavaScriptに伝えてカスタムプロトコルで引っ張ってくるなど)。

どれだけ悲しんでも双方向通信ができない現状を変えることはできないので、この章では一方向通信を試してみます。

JavaScriptからRustにデータを送るにはWRY独自のJavaScript APIであるwindow.ipc.postMessageを用います。 これはwindow.postMessageなどとは異なり文字列の引数を1つだけ取る関数です。 文字列以外の値は勝手に文字列化されて都合が悪いのでJSON化すると良いでしょう。 参考までにTypeScriptでの定義も置いておきます。

interface WryIpc {
  postMessage(message: string): void;
}

declare var ipc: WryIpc;

RustからJavaScriptにデータを送るにはJavaScriptコードを実行するAPIであるwebview.evaluate_scriptを用います。 これは単にJSを実行するだけなので送るデータはJSON化し引数として渡すと良いです。 ただしこのAPIはメインスレッドで実行する必要があります。イベントループにはチャネル的な仕組みが備わっているため、 別スレッドで生成したデータを送信するにはイベントループを通してデータをメインスレッドに送信してこのAPIを呼び出します。

この2つを使ったサンプルを示します。 まずはRust側です。

// JavaScriptに渡す用のデータ
#[derive(Debug, Clone, serde::Serialize)]
struct Data {
    now: u128,
}

// サブスレッドからメインスレッドに送られるイベント
enum UserEvent {
    SendData(Data),
}

fn main() -> wry::Result<()> {
    // イベントを受け取れるようにイベントループを生成する
    let event_loop = EventLoop::<UserEvent>::with_user_event();
    let window = WindowBuilder::new()
        .with_title("はろー")
        .build(&event_loop)?;
    let webview = WebViewBuilder::new(window)?
        // この辺はいい感じに設定
        .with_custom_protocol(...)
        .with_navigation_handler(...)
        .with_new_window_req_handler(...)
        // postMessageで送られたデータを受け取るクロージャ
        .with_ipc_handler(|_win, message| {
            // 受け取ったデータを表示
            println!("from JS: {message}");
        })
        .with_url(...)?
        .build()?;

    {
        // サブスレッドからデータを送信する用のプロキシを生成
        let proxy = event_loop.create_proxy();
        // データを生成するスレッドを起動
        std::thread::spawn(move || loop {
            // データを用意(例として現在時刻のUNIX時間を含む構造体を送る)
            let data = Data {
                now: SystemTime::now()
                    .duration_since(SystemTime::UNIX_EPOCH)
                    .unwrap()
                    .as_millis(),
            };

            // メインスレッドにデータを送信。失敗したらループを抜ける
            if proxy.send_event(UserEvent::SendData(data)).is_err() {
                break;
            }

            // イベントは1秒ごとに送信
            std::thread::sleep(Duration::from_secs(1));
        });
    }

    event_loop.run(move |event, _, control_flow| {
        // ...

        match event {
            // ...
            // データ送信イベント
            Event::UserEvent(UserEvent::SendData(data)) => {
                // データをJSON化して関数を呼び出し
                let r = webview.evaluate_script(&*format!(
                    "fromRust({})",
                    serde_json::to_string(&data).unwrap(),
                ));

                // エラーがあったら表示しておく
                if let Err(e) = r {
                    eprintln!("{e:?}");
                }
            }
            // ...
        }
    });
}

次にJavaScript側のコードを示します。

// 1秒ごとにRustにデータを送信
let n = 0;
setInterval(() => {
  n++;
  window.ipc.postMessage(`n is ${n}`);
}, 1000);

// Rustから呼び出される関数
window.fromRust = data => {
  document.getElementById("data").textContent = data.now;
}

全文

src/main.rs

// [dependencies]
// include_dir = "0.7.3"
// mime_guess = "2.0.4"
// serde = "1.0.147"
// serde_json = "1.0.88"
// wry = "0.22.0"

use std::time::{Duration, SystemTime};

use include_dir::{include_dir, Dir, DirEntry};
use wry::{
    application::{
        event::{Event, StartCause, WindowEvent},
        event_loop::{ControlFlow, EventLoop},
        window::WindowBuilder,
    },
    http::{Request, Response, StatusCode},
    webview::WebViewBuilder,
};

static VIEW: Dir = include_dir!("$CARGO_MANIFEST_DIR/view");

fn custom_protocol_handler(req: &Request<Vec<u8>>) -> wry::Result<Response<Vec<u8>>> {
    let path = req.uri().path().trim_end_matches("/");
    let entry = if path.is_empty() {
        Some(DirEntry::Dir(VIEW.clone()))
    } else {
        VIEW.get_entry(&path[1..]).cloned()
    };
    let file = match entry {
        Some(DirEntry::Dir(dir)) => dir.get_file("index.html").cloned(),
        Some(DirEntry::File(file)) => Some(file),
        None => None,
    };

    match file {
        Some(file) => {
            let mime = mime_guess::from_path(file.path()).first_or_text_plain();

            Response::builder()
                .header("Content-Type", mime.as_ref())
                .body(file.contents().to_vec())
        }
        _ => {
            eprintln!("not found: {}", path);
            Response::builder()
                .status(StatusCode::NOT_FOUND)
                .body(Vec::new())
        }
    }
    .map_err(Into::into)
}

// JavaScriptに渡す用のデータ
#[derive(Debug, Clone, serde::Serialize)]
struct Data {
    now: u128,
}

// サブスレッドからメインスレッドに送られるイベント
enum UserEvent {
    SendData(Data),
}

fn main() -> wry::Result<()> {
    // イベントを受け取れるようにイベントループを生成する
    let event_loop = EventLoop::<UserEvent>::with_user_event();
    let window = WindowBuilder::new()
        .with_title("はろー")
        .build(&event_loop)?;
    let webview = WebViewBuilder::new(window)?
        .with_custom_protocol("wry".to_string(), move |req| custom_protocol_handler(req))
        .with_navigation_handler(|url| {
            url.starts_with("https://wry.localhost/") || url.starts_with("wry://localhost/")
        })
        .with_new_window_req_handler(|_url| false)
        // postMessageで送られたデータを受け取るクロージャ
        .with_ipc_handler(|_win, message| {
            // 受け取ったデータを表示
            println!("from JS: {message}");
        })
        .with_url("wry://localhost/")?
        .build()?;

    {
        // サブスレッドからデータを送信する用のプロキシを生成
        let proxy = event_loop.create_proxy();
        // データを生成するスレッドを起動
        std::thread::spawn(move || loop {
            // データを用意(例として現在時刻のUNIX時間を含む構造体を送る)
            let data = Data {
                now: SystemTime::now()
                    .duration_since(SystemTime::UNIX_EPOCH)
                    .unwrap()
                    .as_millis(),
            };

            // メインスレッドにデータを送信。失敗したらループを抜ける
            if proxy.send_event(UserEvent::SendData(data)).is_err() {
                break;
            }

            // イベントは1秒ごとに送信
            std::thread::sleep(Duration::from_secs(1));
        });
    }

    event_loop.run(move |event, _, control_flow| {
        *control_flow = ControlFlow::Poll;

        match event {
            Event::NewEvents(StartCause::Init) => println!("起動完了"),
            // データ送信イベント
            Event::UserEvent(UserEvent::SendData(data)) => {
                // データをJSON化して関数を呼び出し
                let r = webview.evaluate_script(&*format!(
                    "fromRust({})",
                    serde_json::to_string(&data).unwrap(),
                ));

                // エラーがあったら表示しておく
                if let Err(e) = r {
                    eprintln!("{e:?}");
                }
            }
            Event::WindowEvent {
                event: WindowEvent::CloseRequested,
                ..
            } => *control_flow = ControlFlow::Exit,
            _ => {}
        }
    });
}

view/index.html

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <script type="module" src="/app.js" defer></script>
  </head>
  <body>
  </body>
</html>

view/app.js

// 1秒ごとにRustにデータを送信
let n = 0;
setInterval(() => {
  n++;
  window.ipc.postMessage(`n is ${n}`);
}, 1000);

// Rustから呼び出される関数
window.fromRust = data => {
  document.getElementById("data").textContent = data.now;
}

さいごに

Web部分の配信とRustとWebの連携を紹介しました。紹介した内容だけでもある程度までアプリを作れると思います。 とは言え現状の自由度は低く、ある程度の工夫が求められそうです。 WRYの制約は(内部的にWRYを使う)Tauriの制約にも繋がるところなので、今後Tauriが発展して行くにつれWRYも発展していくことに期待します。

現状のWRYに足りない機能もOSのWebViewに実装されていることもあるので、そちらを直接使った方が罠が少なくて良いかもしれません。 参考までにWRYが使っている各OSのWebView用バインディングクレートを紹介します。

Windows
webview2-com
Linux
webkit2gtk
macOS
objcやらblockやらを 使ってWKWebViewを直接利用

私も今作ろうとしているものはWRYじゃ色々キツそうなので、 とりあえずwebview2-comを使いWindows限定で作ってマルチプラットフォームはあとから考えることにします。