🦔

Rust (Yew+Tauri)で簡単なデスクトップアプリを作る

2023/03/31に公開

前回の記事に続き、今回は指定時間経過後に音楽を止めるタイマーアプリを実際に作ってみたいと思います。
Rustコード越しにWindows Runtimeを叩く必要があるので、Rustで記述できるデスクトップアプリフレームワーク Tauri を採用します。

完成図は以下です。
アプリ完成図

Yew + Tauri の構成でプロジェクトを作成する

TauriはHTML/CSS/JavaScriptを使って見た目を作れるデスクトップアプリフレームワークです。
HTML/CSS/JavaScriptを生成さえできればUI部分に使うフレームワークはReactでもVueでもなんでもいいです。
せっかくなので、今回はRustのフロントエンドフレームワーク Yew を使おうと思います。

プロジェクト作成は簡単です。
Rustを実行できる方の環境には大抵入っているであろうCargoで以下を実行するだけです。

cargo install create-tauri-app
cargo create-tauri-app

create-tauri-appを実行すると、プロジェクト作成に必要ないくつかの情報を聞かれるので、適当に答えましょう。

✔ Project name · stop-sound-timer
✔ Choose which language to use for your frontend · Rust - (cargo)
✔ Choose your UI template · Yew - (https://yew.rs/)

上記のように、フロントエンドで利用する言語およびフレームワークを選ぶことができるので、Yewを選択すればOKです。簡単ですね。

タイマーアプリの実装

create-tauri-appの実行が完了すると以下のようなディレクトリ構成でプロジェクトが作成されます。

stop-sound-timer
  ├─ .vscode
  ├─ public
  ├─ src
  ├─ src-tauri
  ├─ .gitignore
  ├─ .taurignore
  ├─ Cargo.toml
  ├─ index.html
  ├─ README.md
  ├─ styles.css
  └─ Trunk.toml

この状態でcargo tauri devを実行すればサンプルアプリを起動できます。
src/main.rsはYewのエントリポイント、src-tauri/src/main.rsはTauriのエントリポイントになっています。
また、なんだかおしゃれなスタイルが書かれたCSSも入っていたので、これは最後まで流用しました。

それでは、デスクトップアプリを作っていきます。

とはいっても、今回作るのは簡単なタイマーアプリなので、バックエンド側のTauriですることはそんなになく、前回記事のWindows Runtimeを叩くくらいです。

src-tauri/main.rs
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;

#[tauri::command]
fn stop_sound() {
    // 前回記事のコード
    let manager = GlobalSystemMediaTransportControlsSessionManager::RequestAsync().unwrap();
    let session = manager.get().unwrap().GetCurrentSession().unwrap();

    session.TryPauseAsync().unwrap();
}

fn main() {
    // Tauriのエントリポイント
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![stop_sound])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command]というマクロを付与した関数を、Builderでhandlerとして登録することでYew側から使えるようになるみたいです。
Yew側から利用する際は以下のように実行します。

use tauri_sys::tauri;

//...

// 音楽を停止するコマンドをinvokeする
spawn_local(async move { tauri::invoke("stop_sound", &{}).await.unwrap() });

上記のコード中のinvokeによって、Tauri側で定義されたコマンドを実行しています。
invokeの他にもTauri APIでウィンドウを閉じる処理など一般的な操作を行うことができます。
しかし、Tauri APIはJavaScript APIとしてしか提供されていません。
よって、愚直にYew側のコード(=WebAssemblyにビルドされるRustコード)から利用することを考えると、JavaScriptのグルーコードを作る⇒Rustから参照する、という流れが必要になると思うのですが、Tauri APIのラッパーライブラリとしてtauri-sysクレートというものがありました。
このクレートを利用することで、簡単にAPIを呼び出すことができました。ありがたいですね。

次に、フロントエンド側を実装していきます。
Yewでは、レンダリングするHTML要素をhtml!マクロの中に記述します。また、マクロ内のHTMLタグの属性に状態の変数やコールバックを渡します。
YewはReactのFunction ComponentやHooksを踏襲した機能を備えています。本当に似ているので、YewのHooksを利用する前にまずReactのHooksのドキュメントを確認すると理解が早かったです。

src/main.rs
mod app;
mod components;

use app::App;

fn main() {
    // ロガーの初期化
    wasm_logger::init(wasm_logger::Config::default());

    // Yewのエントリポイント
    yew::Renderer::<App>::new().render();
}
src/app.rs
use chrono::NaiveTime;
use yew::prelude::*;

use crate::components::{Timer, TitleBar};

#[function_component(App)]
pub fn app() -> Html {
    let timer_input_ref = use_node_ref();
    let timer_setting = use_state(|| NaiveTime::parse_from_str("00:00:00", "%H:%M:%S").unwrap());

    // タイマー開始ボタンのコールバック
    let start_onclick = {
        let timer_input_ref = timer_input_ref.clone();
        let timer_setting = timer_setting.clone();
        Callback::from(move |_| {
            // タイマーの設定値を取得
            let new_timer_input = timer_input_ref
                .cast::<web_sys::HtmlInputElement>()
                .unwrap()
                .value();

            // 設定値の文字列をNaiveTimeにパース
            let naive_time =
                NaiveTime::parse_from_str(&(String::from("00:") + &new_timer_input), "%H:%M:%S")
                    .unwrap();
            timer_setting.set(naive_time);
        })
    };

    html! {
        <>
            <TitleBar />
            <main class="container">
                <p>{"Until the sound stops :"}</p>
                <Timer time={*timer_setting} />

                <p>
                    <input type="time" ref={timer_input_ref} value="00:00" />
                    <button onclick={start_onclick}> {"Start"} </button>
                </p>
            </main>
        </>
    }
}

上記のコードでは、ボタンが押されたときにstart_onclickが実行され、TimerコンポーネントにNaiveTime型の変数が渡されます。
Timerコンポーネントではタイマーの処理を行い、残り時間を画面描画します。

src/components/timer.rs
use chrono::{Duration, NaiveTime};
use gloo_timers::callback::Timeout;
use stylist::style;
use tauri_sys::tauri;
use wasm_bindgen_futures::spawn_local;
use yew::{classes, function_component, html, use_effect_with_deps, use_state, Html, Properties};

#[derive(Properties, PartialEq)]
pub struct Props {
    pub time: NaiveTime,
}

#[function_component(Timer)]
pub(crate) fn timer(props: &Props) -> Html {
    let local_time = use_state(|| props.time);
    let display_time = use_state(|| props.time.format("%M:%S").to_string());

    {
        // タイマーの設定
        let local_time = local_time.clone();
        let new_time = props.time.clone();
        use_effect_with_deps(
            move |_| {
                local_time.set(new_time);
            },
            props.time,
        );
    }

    {
        // 内部時間の更新に応じて、表示文字列を更新する
        let local_time = local_time.clone();
        let display_time = display_time.clone();
        use_effect_with_deps(
            move |local_time| {
                let new_display_time = local_time.format("%M:%S").to_string();
                display_time.set(new_display_time.clone());
            },
            local_time,
        )
    }

    {
        // タイマーの実行
        let local_time = local_time.clone();
        use_effect_with_deps(
            move |local_time| {
                let local_time = local_time.clone();

                // 1秒ごとに実行
                let timeout = Timeout::new(1_000, move || {
                    let zero = naive_time_from_zero();
                    let new_time = {
                        if *local_time.clone() != zero {
                            *local_time.clone() - Duration::seconds(1)
                        } else {
                            // 内部時間が0になったらデクリメントを停止する
                            *local_time.clone()
                        }
                    };
                    local_time.set(new_time);

                    // タイマーが0になったとき
                    if new_time == zero && new_time < *local_time {
                        // 音楽を停止するコマンドをinvokeする
                        spawn_local(async move { tauri::invoke("stop_sound", &{}).await.unwrap() });
                    }
                });

                // クリーンアップ時の処理でTimeoutをクリアする
                || drop(timeout)
            },
            local_time,
        );
    }

    // スタイルは省略...

    html! {
        <>
            <div >
                <p class={if &*display_time.clone() == "00:00"{
                    classes!(time_style, flash_style)
                } else {
                    classes!(time_style)
                }}>{&*display_time.clone()}</p>
            </div>
        </>
    }
}

/// 0時0分0秒のNaiveTimeを返します
fn naive_time_from_zero() -> NaiveTime {
    NaiveTime::from_hms_opt(0, 0, 0).unwrap()
}

Yew側のコードはWebAssemblyにビルドされてWebViewで実行されるので、Tokioなどのライブラリのタイマーは使えません。
そのため、gloo_timersクレートのTimeoutを利用しています。
gloo_timersクレートは内部的にはブラウザのAPIを呼び出しているようです。

まとめ

Yew + Tauriを使って簡単なデスクトップアプリを作りました。
フロントエンドをRustで書くかどうかはともかく、Tauriの使い方自体はドキュメントも充実しているため、そこまでとっつきにくくはないかなという印象です。
次はもうちょっと複雑なアプリにチャレンジしてみたいですね。

今回作成したアプリは以下のリポジトリにあります。どなたかの参考になれば嬉しいです!
https://github.com/oyasumi731/stop-sound-timer

Discussion