Open7

Tauriのカスタムプロトコルを探ってみる

maemonmaemon

動機

Tauriは初期化時にフロントエンドにRustを選べる.

YewがReact風にかけて気に入ってるのでTauri + Yewで作りたい.

Yewのプリセットは用意されてる.

当然だけど,Yewを使ってもJSのAPIを通してTauriとやり取りする.

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = ["window", "__TAURI__", "tauri"])]
    async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}

async fn greet(name: &str) -> JsValue {
    #[derive(Serialize, Deserialize)]
    struct Args<'a> {
        name: &'a str,
    }

    let args = to_value(&Args { name }).unwrap();

    invoke("greet", args).await.as_string().unwrap()
}

これはTauriのコマンドを使ったIPCだけど,常に文字列でやり取りするらしい.バイナリを送りたければBase64エンコードとかをしなければいけない.

https://github.com/tauri-apps/tauri/issues/7127

小さなデータなら問題ないけど,ちょっと嫌な感じ.

maemonmaemon

コマンドではない方法として,Tauriが公開しているAPIを叩けばいいかも? ということで試しにhttpモジュールを調べてみる.

https://tauri.app/v1/api/js/http

async function fetch<T>(url: string,options?: FetchOptions): Promise<Response<T>>;

interface HttpOptions {
  method: HttpVerb
  url: string
  headers?: Record<string, any>
  query?: Record<string, any>
  body?: Body
  timeout?: number | Duration
  responseType?: ResponseType
}

type FetchOptions = Omit<HttpOptions, 'url'>

これをYewから使おうとすると,

#[wasm_bindgen(js_namespace=["window", "__TAURI__", "http"])]
extern "C" {
    #[wasm_bindgen(js_name = fetch)]
    pub async fn fetch(url: &str, opions: JsValue) -> JsValue;
}

#[derive(Debug, serde::Serialize)]
pub struct HttpOptions {
    pub method: Method,
    pub url: Url,
    pub headers: Option<MultiMap<HeaderName, HeaderValue>>,
    pub query: Option<HashMap<String, String>>,
    pub body: Option<Body>,
    pub timeout: Option<Duration>,
    pub response_type: Option<ResponseType>,
}

こんな感じのバインディングを作って呼ぶ.Rustの型 -> JsValueへの変換が至る所に出現することになる.

特にRustのString -> JsStringへの変換が辛い. UTF-8 -> UTF-16への変換で頻出する割に重たい.

https://rustwasm.github.io/wasm-bindgen/api/wasm_bindgen/fn.intern.html

一応改善するすべとしてJsStringをキャッシュするintern関数がある.

maemonmaemon

HttpOptionsBodyにはバイナリが渡せる.

import { Body } from "@tauri-apps/api/http"
Body.bytes(new Uint8Array([1, 2, 3]));

Tauri側で用意されてるAPIだし,コマンドで渡すよりマシなはず? 調べてみる.

https://github.com/tauri-apps/tauri/blob/a3277a245c191a28b3062156d09c0916fe232025/tooling/api/src/http.ts#L525C1-L533C2

import { invokeTauriCommand } from './helpers/tauri'

// ...

async function getClient(options?: ClientOptions): Promise<Client> {
  return invokeTauriCommand<number>({
    __tauriModule: 'Http',
    message: {
      cmd: 'createClient',
      options
    }
  }).then((id) => new Client(id))
}

どうやらinvokeTauriCommandを呼んでるらしい.

import { invoke } from '../tauri'

// ...

async function invokeTauriCommand<T>(command: TauriCommand): Promise<T> {
  return invoke('tauri', command)
}

これ,コマンドの呼び出しで使ったinvokeじゃん.

つまり,自前のコマンドと同じで全部文字列を介してやり取りしている.

maemonmaemon

本題

どうにかしてバイナリをそのまま渡したい.Tauriにはカスタムプロトコルを定義できる機能があるようなのでそれを調べる.

公式ページには説明はないが,examplesにはあった
https://github.com/tauri-apps/tauri/blob/1.x/examples/streaming

Builderのregister_uri_scheme_protocolでカスタムプロトコルを登録するようだ.使ってみる.


お試しRepo
https://github.com/maemon4095/tauri-custom-protocol

単にリクエストを受け取った時間をUTF-8文字列で返すだけのプロトコルを登録する.

main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![])
        .register_uri_scheme_protocol("mybinary", |app, req| {
            let now = chrono::Local::now();
            let now = format!("Request received at: {}", now.to_rfc3339());

            tauri::http::ResponseBuilder::new()
                .header("Access-Control-Allow-Origin", "*")
                .body(now.as_bytes().to_vec())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
App.tsx
import React, { useState } from "react";
export default function App() {
  const [response, setResponse] = useState("");

  const onClick = async (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
  ) => {
    const res = await fetch("mybinary:");
    const decoded = res.body!.pipeThrough(new TextDecoderStream("utf8"));

    setResponse((await decoded.getReader().read()).value!);
  };

  return (
    <div className="container">
      <button onClick={onClick}>
        send custom protocol request!
      </button>
      <p>{response}</p>
    </div>
  );
}

う ご か な い

maemonmaemon

どうやらWindowsではカスタムURLスキームは使えず,サブドメインにプロトコル書くらしい.

https://github.com/tauri-apps/tauri/issues/5333#issuecomment-1265203330

URLを修正して,

App.tsx
import React, { useState } from "react";
export default function App() {
  const [response, setResponse] = useState("");

  const onClick = async (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
  ) => {
-    const res = await fetch("mybinary:");
+    const res = await fetch("https://mybinary.localhost/");
    const decoded = res.body!.pipeThrough(new TextDecoderStream("utf8"));

    setResponse((await decoded.getReader().read()).value!);
  };

  return (
    <div className="container">
      <button onClick={onClick}>
        send custom protocol request!
      </button>
      <p>{response}</p>
    </div>
  );
}

動いた.

maemonmaemon

とりあえずバイナリを直接送ることはできた.これにMemoryPack的なシリアライザを加えれば,えくすとりーむふぁすとなIPCができるはず.(MemoryPackはC#だけど)

bincodeというシリアライザがMemoryPackと同じような仕組みらしいので,これを使っていくとよさそう.

maemonmaemon

おまけ

カスタムプロトコルを書けるなら,双方向ストリームとか作れないかな?
Windowsではhttpsしか使えないけど,piping-serverみたいなことはできそう.

register_uri_scheme_protocolでのレスポンスは固定長のバッファしか許していない.どうにかしてストリーミングできないか.

https://docs.rs/wry/0.34.2/wry/webview/struct.WebViewBuilder.html#method.with_asynchronous_custom_protocol

pub fn with_asynchronous_custom_protocol<F>(
    self,
    name: String,
    handler: F
) -> Self
where
    F: Fn(Request<Vec<u8>>, RequestAsyncResponder) + 'static,

WryのAPIもバッファを受け取るだけ.Asyncになってるけど.

さらに深堀りする.TauriのDocsに依れば,プラットフォーム毎のカスタムプロトコルの登録は

を使っているらしい.Windowsを使っているのでWebView2を調べてみる.

AddWebResourceRequestedFilterは呼び出されるイベントの種類とURLのフィルタを登録する関数らしい.登録したフィルタを通過したイベントはWebResourceRequestedイベントを発火する模様.

WebResourceRequestedCoreWebView2WebResourceRequestedEventArgsを引数に発火する.CoreWebView2WebResourceRequestedEventArgsResponseプロパティを持ち,これにセットすることで応答するみたい.

これの型はCoreWebView2WebResourceResponseStream型のContentというプロパティを持つ.

ContentStream型なので,ストリーミングできそうな感じがするけど,

Stream must have all the content data available by the time the WebResourceRequested event deferral of this response is completed. Stream should be agile or be created from a background thread to prevent performance impact to the UI thread. null means no content data.

となっているので,無理なのかもしれない.