TauriのIPC戦略

What is Tauri

Tauriは Web フロントエンドの技術で GUI を作れるフレームワークです。

似た技術としてはElectronがあります。

大きな違いとしては Electron はレンダリングエンジンに Chromium を使っているのに対して、Tauri では各 OS の Webview を使用しています。 これによりバンドルサイズの削減やレンダリングエンジンとアプリケーションを切り離して考えることができます。

What is IPC

IPC とはプロセス間通信のことです。異なるプロセスで動作するプログラム間の通信に使用されます。
Tauri や Electron では Renderer process と Application process 間で通信するために使われます。

コードを読んでいく

クライアント

まずはクライアントで HTTP request を送信するコードを見てみます。

import { getClient, Body, ResponseType } from "@tauri-apps/api/http";

const client = await getClient();

client.request({
url: "http://localhost:8080",
method: "GET",
});

Tauri ではセキュリティの観点から I/O を伴う API を opt-in しなければ使えないようになっています。 そのため I/O などの OS 依存の処理はブラウザの API は使わずに Tauri から提供される API を使用します。

上記のコードではgetClientから受け取ってclientを通してrequestを飛ばしています。 このrequestは Renderer process で実行されますが、OS の処理を伴うためバックエンドで実行する必要があります。 そのため IPC を通してバックエンドにメッセージを飛ばしてバックエンド側で実際の HTTP リクエストを発行しています。

createClientの場合、IPC では__TAURI_IPC__を通して以下のようなオブジェクトが送られます。

const _ = {
cmd: {
__tauriModule: "Http",
message: { cmd: "createClient" /* .... */ },
},
callback: { ランダムに生成されたcallbackのID },
error: { ランダムに生成されたcallbackのID },
};

__TAURI_IPC__の内部ではwindow.ipcが呼び出されています。ipcプロパティは Wry 側で以下のように定義されています(macOS の wkwebview の場合)。

Object.defineProperty(window, "ipc", {
value: Object.freeze({
postMessage: function (s) {
window.webkit.messageHandlers.ipc.postMessage(s);
},
}),
});

バックエンド

次にバックエンドのコードを見てみます。

IPC の受け取り側は Wry で定義されています。WebViewBuilder::with_ipc_handlerという関数にipc_handlerを渡すと IPC を受け取るたびに呼びだされます。

次にipc_handlerの作成についてみていきます。

エントリーポイントであるBuilder::buildを実行すると、この関数の内部で Window マネージャーや IPC 関連の処理を準備するprepare_windowという関数が呼ばれます。

この関数の内部では以下のようなコードが呼ばれており、ここでipc_handlerのセットアップがなされます。

pending.ipc_handler = Some(self.prepare_ipc_handler(app_handle));

この関数の内部では IPC メッセージを受け取ったときに実行されるコールバック関数を定義しています。

fn prepare_ipc_handler(
&self,
app_handle: AppHandle<R>,
) -> WebviewIpcHandler<EventLoopMessage, R> {
// ...
Box::new(move |window, #[allow(unused_mut)] mut request| {
// ...

match serde_json::from_str::<InvokePayload>(&request) {
Ok(message) => {
let _ = window.on_message(message);
}
Err(e) => {
let error: crate::Error = e.into();
let _ = window.eval(&format!(
r#"console.error({})"#,
JsonValue::String(error.to_string())
));
}
}
})
}

コールバック関数の引数から受け取る request は JSON 文字列として送られてくるのでそれをserde_jsonでパースしてInvokePayload構造体に割り当てます。

パースが成功するとWindow::on_messageに message が送られます。

pub fn on_message(self, payload: InvokePayload) -> crate::Result<()> {
let manager = self.manager.clone();
match payload.cmd.as_str() {
"__initialized" => {
// ...
}
_ => {
let message = InvokeMessage::new(
self.clone(),
manager.state(),
payload.cmd.to_string(),
payload.inner,
);
let resolver = InvokeResolver::new(self, payload.callback, payload.error);

let invoke = Invoke { message, resolver };
if let Some(module) = &payload.tauri_module {
crate::endpoints::handle(
module.to_string(),
invoke,
manager.config(),
manager.package_info(),
);
} // ...

// ...
}
}

Ok(())
}

上記のコードを見るとpayload.tauri_moduleが存在する場合にcrate::endpoints::handleが呼び出されています。crate::endpoints::handleは以下のようなコードになっています。

fn handle<R: Runtime>(
module: String,
invoke: Invoke<R>,
config: Arc<Config>,
package_info: &PackageInfo,
) {
let Invoke { message, resolver } = invoke;
let InvokeMessage {
mut payload,
window,
..
} = message;

if let JsonValue::Object(ref mut obj) = payload {
obj.insert("module".to_string(), JsonValue::String(module.clone()));
}

match serde_json::from_value::<Module>(payload) {
Ok(module) => module.run(window, resolver, config, package_info.clone()),
Err(e) => {
// ...
}
}
}

ここではpayloadModuleという enum に変換した後、module.runを呼び出しています。
ここでのpayloadは先ほどの関数の payload とは違います。
IPC から受け取るInvokePayloadは以下のような構造体になっており、Tauri の処理で必要な値とユーザーからの値を分けています。

pub struct InvokePayload {
// === Tauriの処理に必要な値 ===
/// The invoke command.
pub cmd: String,
#[serde(rename = "__tauriModule")]
#[doc(hidden)]
pub tauri_module: Option<String>,
/// The success callback.
pub callback: CallbackFn,
/// The error callback.
pub error: CallbackFn,

// === ユーザーから指定される値 ===
/// The payload of the message.
#[serde(flatten)]
pub inner: JsonValue,
}

crate::endpoints::handleInvokeMessageから受け取っているpayloadにはInvokePayloadinnerプロパティが渡されています。

次にmodule.runを見ていきます。

ここで使われるmoduleは enum であり以下のように定義されています。

enum Module {
App(app::Cmd),
#[cfg(process_any)]
Process(process::Cmd),
#[cfg(fs_any)]
Fs(file_system::Cmd),
#[cfg(os_any)]
Os(operating_system::Cmd),
#[cfg(path_any)]
Path(path::Cmd),
Window(Box<window::Cmd>),
#[cfg(shell_any)]
Shell(shell::Cmd),
Event(event::Cmd),
#[cfg(dialog_any)]
Dialog(dialog::Cmd),
#[cfg(cli)]
Cli(cli::Cmd),
Notification(notification::Cmd),
#[cfg(http_any)]
Http(http::Cmd),
#[cfg(global_shortcut_any)]
GlobalShortcut(global_shortcut::Cmd),
#[cfg(clipboard_any)]
Clipboard(clipboard::Cmd),
}

これらの値をmodule.runの中で判定してそれぞれのコマンドに応じた処理を行います。

fn run<R: Runtime>(
self,
window: Window<R>,
resolver: InvokeResolver<R>,
config: Arc<Config>,
package_info: PackageInfo,
) {
let context = InvokeContext {
window,
config,
package_info,
};
match self {
Self::App(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(process_any)]
Self::Process(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(fs_any)]
Self::Fs(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(path_any)]
Self::Path(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(os_any)]
Self::Os(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
Self::Window(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.await
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(shell_any)]
Self::Shell(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
Self::Event(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(dialog_any)]
Self::Dialog(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(cli)]
Self::Cli(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
Self::Notification(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(http_any)]
Self::Http(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.await
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(global_shortcut_any)]
Self::GlobalShortcut(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
#[cfg(clipboard_any)]
Self::Clipboard(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),
}
}

最初の例で確認した HTTP の処理は以下の行です。

#[cfg(http_any)]
Self::Http(cmd) => resolver.respond_async(async move {
cmd
.run(context)
.await
.and_then(|r| r.json)
.map_err(InvokeError::from_anyhow)
}),

ここではcmd.runが呼ばれており、これが実際に HTTP 関連の処理を実行する関数です。 cmd の定義は以下のような enum になっており、CommandModulederive macro によってrun関数が生成されています。

/// The API descriptor.
#[command_enum]
#[derive(Deserialize, CommandModule)]
#[cmd(async)]
#[serde(tag = "cmd", rename_all = "camelCase")]
pub enum Cmd {
/// Create a new HTTP client.
#[cmd(http_request, "http > request")]
CreateClient { options: Option<ClientBuilder> },
/// Drop a HTTP client.
#[cmd(http_request, "http > request")]
DropClient { client: ClientId },
/// The HTTP request API.
#[cmd(http_request, "http > request")]
HttpRequest {
client: ClientId,
options: Box<HttpRequestBuilder>,
},
}

run関数は macro で生成されているため macro の一部のみを載せます。実際のコードはcore/tauri_macros/src/command_modules.rsgenerate_run_fnにあります。

matcher = TokenStream2::new();
for variant in &data_enum.variants {
// ...

matcher.extend(quote_spanned! {
variant.span() => #maybe_feature_check #name::#variant_name #fields_in_variant => #name::#variant_execute_function_name(context, #variables)#maybe_await.map(Into::into),
});

// ...
}

上記のコードではproc_macroを使用して enum をパースし、各 variants へ対応する match 式のアーム(?)を matcher にアサインしています。
variant_execute_function_nameは enum の variants を lowercase&snake_case に変更したもの文字列が入っています。
これにより例えば、CreateClientvariant はcreate_clientのようになり、対応する関数を呼び出せるようにしています。
さらに最後のInto::intoserde_jsonによって JavaScript に渡せるように整形しています。

Cmdenum に対応するimplは以下のようになっており、enum の variant に対応した関数が定義されています。

impl Cmd {
#[module_command_handler(http_request)]
async fn create_client<R: Runtime>(
_context: InvokeContext<R>,
options: Option<ClientBuilder>,
) -> super::Result<ClientId> {
// ...
}

#[module_command_handler(http_request)]
async fn drop_client<R: Runtime>(
_context: InvokeContext<R>,
client: ClientId,
) -> super::Result<()> {
// ...
}

#[module_command_handler(http_request)]
async fn http_request<R: Runtime>(
context: InvokeContext<R>,
client_id: ClientId,
options: Box<HttpRequestBuilder>,
) -> super::Result<ResponseData> {
// ...
}
}

一連の処理が終わるとresolver.respond_asyncの内部でwindow.invoke_responder()が呼ばれます。

invoke_responderにはArc::new(window_invoke_responder)が渡されています。

window_invoke_responderは以下のような定義になっています。

pub fn window_invoke_responder<R: Runtime>(
window: Window<R>,
response: InvokeResponse,
success_callback: CallbackFn,
error_callback: CallbackFn,
) {
let callback_string =
match format_callback_result(response.into_result(), success_callback, error_callback) {
Ok(callback_string) => callback_string,
Err(e) => format_callback(error_callback, &e.to_string())
.expect("unable to serialize response string to json"),
};

let _ = window.eval(&callback_string);
}

ここで引数から受け取っているresponse,success_callback,error_callbackは JavaScript から受け取った値であり、文字列として値を保持しています。
format_callback_resultではresponseをそれぞれの callback に渡すように文字列を整形します。 さらにwindow.evalへ値を渡すことで webview でそれぞれの JavaScript のコードを実行しています。

format_callback_resultは以下のようなコードになっています。

pub fn format_callback_result<T: Serialize, E: Serialize>(
result: Result<T, E>,
success_callback: CallbackFn,
error_callback: CallbackFn,
) -> crate::api::Result<String> {
match result {
Ok(res) => format_callback(success_callback, &res),
Err(err) => format_callback(error_callback, &err),
}
}

さらにformat_callbackのコードは以下のようになっています。

pub fn format_callback<T: Serialize>(
function_name: CallbackFn,
arg: &T,
) -> crate::api::Result<String> {
serialize_js_with(arg, Default::default(), |arg| {
format!(
r#"
if (window["_{fn}"]) else "#
,
fn = function_name.0,
arg = arg
)
})
}

ここで callback には名前がついていることがわかります。 Tauri では callback それぞれに uid で名前を付与して window に値を保存しています。

function uid(): number {
window.crypto.getRandomValues(new Uint32Array(1))[0];
}

最後にwindow.evalでは内部でeval_scriptという関数が呼ばれています。この関数の中ではMessage::Webviewというカスタムイベントが Window のイベントループに送られます。

fn eval_script<S: Into<String>>(&self, script: S) -> Result<()> {
send_user_message(
&self.context,
Message::Webview(
self.window_id,
WebviewMessage::EvaluateScript(script.into()),
),
)
}

イベントループの中では以下のような match 式が定義されており、Message::Webviewイベントをハンドリングしています。

Message::Webview(id, webview_message) => match webview_message {
// ...
WebviewMessage::EvaluateScript(script) => {
if let Some(WindowHandle::Webview { inner: webview, .. }) =
windows.borrow().get(&id).and_then(|w| w.inner.as_ref())
{
if let Err(e) = webview.evaluate_script(&script) {
debug_eprintln!("{}", e);
}
}
}
// ...
}

WebviewMessage::EvaluateScript(script)の中でwebview.evaluate_script(&script)が呼ばれています。これが実際に Webview で JavaScript を eval する処理になります。

実際の eval のコードは以下のように実行されています。
macOS のユーザーなので macOS のコードを載せますが実際には各 OS ごとの Webview で eval するコードが記述されています。
Tauri の webview を抽象化しているライブラリは Wry というライブラリです。macOS では objc という Rust で Objective-C を書けるライブラリを使用しています。

let _: id = msg_send![self.webview, evaluateJavaScript:NSString::new(js) completionHandler:null::<*const c_void>()];

これで一連の流れを確認できました。 流れをまとめると以下のようなフローになります。

  1. Client から IPC メッセージを送る
  2. Webview(Wry)から IPC メッセージを受け取る
  3. Tauri でメッセージのcmdを確認し、それぞれのコマンドを実行する
  4. Webview で JavaScript の eval を通して実行結果を返却
  5. Client で callback から結果を受け取る

セキュリティ

Tauri のIsolation patternではよりセキュアに IPC を実現できます。

Tauri が IPC を通して I/O を伴い処理を行うのも脆弱性の被害を最小限にするためです。
IPC は中間者攻撃や XSS などから情報を抜きとられたり意図しない操作をされる可能性を孕みます。
そのため Tauri の Isolation pattern では IPC の操作を iframe 経由に限定し、送るデータを暗号化することで信頼性を担保しています。

実際にコードを見ていきます。

以下のように iframe を作成します。

window.addEventListener("DOMContentLoaded", () => {
if (window.location.origin.startsWith(__TEMPLATE_origin__)) {
let style = document.createElement("style");
style.textContent = __TEMPLATE_style__;
document.head.append(style);

let iframe = document.createElement("iframe");
iframe.id = "__tauri_isolation__";
iframe.sandbox.add("allow-scripts");
iframe.src = __TEMPLATE_isolation_src__;
document.body.append(iframe);
}
});

さらに以下のようなコードを通して iframe へ message を送ります。

function sendIsolationMessage(data) {
// set the frame dom element if it's not been set before
if (!isolation.frame) {
const frame = document.querySelector("iframe#__tauri_isolation__");
if (frame.src.startsWith(isolationOrigin)) {
isolation.frame = frame;
} else {
console.error(
"Tauri IPC found an isolation iframe, but it had the wrong origin"
);
}
}

// ensure we have the target to send the message to
if (!isolation.frame || !isolation.frame.contentWindow) {
console.error(
'Tauri "Isolation" Pattern could not find the Isolation iframe window'
);
return;
}

isolation.frame.contentWindow.postMessage(
data,
"*" /* todo: set this to the secure origin */
);
}

iframe 側で message イベントを待ち受けます。

async function payloadHandler(event) {
if (!isIsolationPayload(event)) {
return;
}

let data = event.data;

if (typeof window.__TAURI_ISOLATION_HOOK__ === "function") {
// await even if it's not async so that we can support async ones
data = await window.__TAURI_ISOLATION_HOOK__(data);
}

const encrypted = await encrypt(data);
sendMessage(encrypted);
}

window.addEventListener("message", payloadHandler, false);

message イベントではpayloadHandlerが呼ばれています。この関数内ではencrypt(data)の後、sendMessageしています。

encrypt関数の内部は以下のようになっています。

/**
* @type {Uint8Array} - Injected by Tauri during runtime
*/

const aesGcmKeyRaw = new Uint8Array(__TEMPLATE_runtime_aes_gcm_key__);

/**
* @type {CryptoKey}
*/

const aesGcmKey = await window.crypto.subtle.importKey(
"raw",
aesGcmKeyRaw,
"AES-GCM",
true,
["encrypt"]
);

async function encrypt(data) {
let algorithm = Object.create(null);
algorithm.name = "AES-GCM";
algorithm.iv = window.crypto.getRandomValues(new Uint8Array(12));

let encoder = new TextEncoder();
let payloadRaw = encoder.encode(JSON.stringify(data));

return window.crypto.subtle
.encrypt(algorithm, aesGcmKey, payloadRaw)
.then((payload) => {
let result = Object.create(null);
result.nonce = Array.from(new Uint8Array(algorithm.iv));
result.payload = Array.from(new Uint8Array(payload));
return result;
});
}

暗号化にはAES-GCMという共通鍵暗号の仕組みが使われています。 バックエンド側で生成した鍵をあらかじめ共有しておき、その鍵を使って IPC のやりとりを行います。

これでセキュアに IPC のやりとりができるようになりました。