Tauriのカスタムプロトコルを探ってみる
動機
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エンコードとかをしなければいけない.
小さなデータなら問題ないけど,ちょっと嫌な感じ.
コマンドではない方法として,Tauriが公開しているAPIを叩けばいいかも? ということで試しに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への変換で頻出する割に重たい.
一応改善するすべとしてJsStringをキャッシュするintern関数がある.
HttpOptionsのBodyにはバイナリが渡せる.
import { Body } from "@tauri-apps/api/http"
Body.bytes(new Uint8Array([1, 2, 3]));
Tauri側で用意されてるAPIだし,コマンドで渡すよりマシなはず? 調べてみる.
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じゃん.
つまり,自前のコマンドと同じで全部文字列を介してやり取りしている.
本題
どうにかしてバイナリをそのまま渡したい.Tauriにはカスタムプロトコルを定義できる機能があるようなのでそれを調べる.
公式ページには説明はないが,examplesにはあった
Builderのregister_uri_scheme_protocolでカスタムプロトコルを登録するようだ.使ってみる.
お試しRepo
単にリクエストを受け取った時間をUTF-8文字列で返すだけのプロトコルを登録する.
#![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");
}
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>
);
}
う ご か な い

どうやらWindowsではカスタムURLスキームは使えず,サブドメインにプロトコル書くらしい.
URLを修正して,
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>
);
}
動いた.

とりあえずバイナリを直接送ることはできた.これにMemoryPack的なシリアライザを加えれば,えくすとりーむふぁすとなIPCができるはず.(MemoryPackはC#だけど)
bincodeというシリアライザがMemoryPackと同じような仕組みらしいので,これを使っていくとよさそう.
おまけ
カスタムプロトコルを書けるなら,双方向ストリームとか作れないかな?
Windowsではhttpsしか使えないけど,piping-serverみたいなことはできそう.
register_uri_scheme_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に依れば,プラットフォーム毎のカスタムプロトコルの登録は
- macOS : setURLSchemeHandler
- Windows : AddWebResourceRequestedFilter
- Linux : webkit-web-context-register-uri-scheme
を使っているらしい.Windowsを使っているのでWebView2を調べてみる.
AddWebResourceRequestedFilterは呼び出されるイベントの種類とURLのフィルタを登録する関数らしい.登録したフィルタを通過したイベントはWebResourceRequestedイベントを発火する模様.
WebResourceRequestedはCoreWebView2WebResourceRequestedEventArgsを引数に発火する.CoreWebView2WebResourceRequestedEventArgsはResponseプロパティを持ち,これにセットすることで応答するみたい.
これの型はCoreWebView2WebResourceResponseでStream型のContentというプロパティを持つ.
ContentがStream型なので,ストリーミングできそうな感じがするけど,
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.
となっているので,無理なのかもしれない.