2024年6月28日金曜日

Tauri でドラッグアンドドロップでファイルを受け取る

今回は、ドラッグアンドドロップでファイルを受け取るのをやっていく。

コンテナだと動作確認が無理なので、 Windows に環境を入れて実施した。

前提

  • OS: Windows 11 Pro 23H2 ビルド 22631.3737
  • Tauri:
    • rustup: 1.27.1
    • rust: 1.79.0
    • node: v20.11.1
    • tauri-cli: 10.2.4

プロジェクト作成

> cargo install create-tauri-app --locked
> cargo create-tauri-app
 Project name · draganddrop
 Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, bun)
 Choose your package manager · npm
 Choose your UI template · React - (https://react.dev/)
 Choose your UI flavor · TypeScript

Template created! To get started run:
  cd draganddrop
  npm install
  npm run tauri dev

実装

今回は、さくっとコードを載せるだけ。コメントを見れば大体わかるでしょう。

編集対象はフロントエンドのみ。

src/App.tsx:

import { useEffect, useState } from "react";
import "./App.css";
import { appWindow } from "@tauri-apps/api/window";

function App() {

  const [dropFiles, setDropFiles] = useState<string[]>([]);

  useEffect(() => {
    let unlisten: any = null;
    if (!unlisten) {
      (async () => {
        // ファイルのドロップを購読
        unlisten = await appWindow.onFileDropEvent((event) => {
          if (event.payload.type === 'hover') {
            console.log('User hovering', event);
          } else if (event.payload.type === 'drop') {
            console.log('User dropped', event);
            setDropFiles(event.payload.paths);
          } else {
            console.log('File drop cancelled');
          }
        });
      })();
    }

    return () => {
      if (unlisten) {
        unlisten();
      }
    }
  })

  return (
    <div className="container">
      <h1>Welcome to Tauri!</h1>

      <h2>Drop files:</h2>
      <ul>
        { /* ドラッグアンドドロップで受け取ったファイルのファイルパスを表示 */
          dropFiles.map((filePath) => <li key={filePath}>{filePath}</li>)
        }
      </ul>

    </div>
  );
}

export default App;

動作確認

npm run tauri dev で実行し、ファイルをドロップすると、 画面下部にドラッグされたファイルのファイルパスが表示される。

良さそう。

以上。

参考資料

2024年6月26日水曜日

Tauri でマルチウィンドウ(動的生成編)

今回は、マルチウィンドウ定義を動的に作るのをやっていく。

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0

前回のものに追加する形でやっていくので、差分を見てください。

実装

今回は、以下の順に実装していく。

  1. 初期ウィンドウ表示を main だけにする
  2. フロントエンド実装
    1. src-tauri/tauri.conf.json にウィンドウ操作の許可設定を追加
    2. 実装
  3. バックエンドエンド実装

フロントエンド実装・バックエンド実装はそれぞれ独立しているので必要などちらか片方だけでも OK。

初期ウィンドウ表示を main だけにする

起動時に、メインウィンドウとのみが表示されるように、前回追加したサブの定義を削除。

...(snip)
  "tauri": {
    ...(snip)
    "windows": [
      {
        "label": "main",
        "title": "Main Window",
        "url": "index.html",
        "height": 1000
      },
    ],
...(snip)

src-tauri/tauri.conf.json にウィンドウ操作の許可設定を追加

フロントエンドからウィンドウ操作をするには、 src-tauri/tauri.conf.jsonallowList に許可設定を追加する必要がある。

今回は、 tauri/allowlist/window/createtrue にする。

...(snip)
  "tauri": {
    "allowlist": {
      ...(snip)
      "window": {
        "create": true
      }
      ...(snip)
    },
...(snip)

これで、フロントエンドからウィンドウの生成を実行できるようになる。

フロントエンド実装

src/App.tsx:

フロントエンドから直接ウィンドウを生成するものと、 コマンド経由でバックエンドに生成を依頼するものの、 2 種類計 4 個のボタンを追加。

new してから操作するまでや、エラー取得の流れが独特なので気を付けること。

...(snip)
      <button onClick={() => {
        const mainWindow = new WebviewWindow('main', {
          title: 'Main Window',
          url: 'index.html',
        });
        mainWindow.once('tauri://created', () => {
          // ウィンドウ生成後、何かしたい場合はここに記述する
        });

        mainWindow.once('tauri://error', function(e) {
          console.log(e);
        });
      }}>フロントエンドからメインウィンドウを開く</button>
      <button onClick={() => {
        const subWindow = new WebviewWindow('sub', {
          title: 'Sub Window',
          url: 'index.html',
        });
        subWindow.once('tauri://created', () => {
          // ウィンドウ生成後、何かしたい場合はここに記述する
        });

        subWindow.once('tauri://error', function(e) {
          console.log(e);
        });
      }}>フロントエンドからサブウィンドウを開く</button>

      <button onClick={() => {
        invoke('open_main_window', {});
      }}>バックエンドからメインウィンドウを開く</button>
      <button onClick={() => {
        invoke('open_sub_window', {});
      }}>バックエンドからサブウィンドウを開く</button>
...(snip)

バックエンド実装

src-tauri/src/main.rs:

フロントエンドから open_main_window, open_sub_window のコマンドを呼び出すようにしたので、 それらコマンドを実装し、それぞれ対象のウィンドウにイベントを発行するように実装。

...(snip)
#[tauri::command]
fn open_main_window(app_handle: AppHandle) {
    let result = tauri::WindowBuilder::new(
        &app_handle,
        "main",
        tauri::WindowUrl::App("index.html".into()),
    )
    .build();

    // 既にウィンドウが開いている場合は Err が返ってくるので、match で判定して処理
    match result {
        Ok(main_window) => main_window.set_title("Main Window").unwrap(),
        Err(error) => println!("{:?}", error),
    }
}

#[tauri::command]
fn open_sub_window(app_handle: AppHandle) {
    let result = tauri::WindowBuilder::new(
        &app_handle,
        "sub",
        tauri::WindowUrl::App("index.html".into()),
    )
    .build();

    // 既にウィンドウが開いている場合は Err が返ってくるので、match で判定して処理
    match result {
        Ok(sub_window) => sub_window.set_title("Sub Window").unwrap(),
        Err(error) => println!("{:?}", error),
    }
}
...(snip)
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            open_main_window,
            open_sub_window,
            to_all,
            to_main,
            to_sub
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

動作確認

×でどちらかのウィンドウを消したあと、ウィンドウ作成ボタを押下すると、ウィンドウが表示される。 良さそう。

以上。

参考資料

Tauri でマルチウィンドウ(静的定義編)

今回は、マルチウィンドウ定義を静的に設定するのをやっていく。

そして、前回やらなかった個別ウィンドウへ向けたイベント送信をやってみる。

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0

実装

今回は、以下の順に実装していく。

  1. src-tauri/tauri.conf.json にウィンドウ定義を追加
  2. フロントエンド実装
  3. バックエンドエンド実装

フロントエンド実装・バックエンド実装はそれぞれ独立しているので必要などちらか片方だけでも OK。

src-tauri/tauri.conf.json にウィンドウ定義を追加

起動時に、メインウィンドウとサブウィンドウのふたつが表示されるように設定。

...(snip)
  "tauri": {
    ...(snip)
    "windows": [
      {
        "label": "main",
        "title": "Main Window",
        "url": "index.html",
        "height": 1000
      },
      {
        "label": "sub",
        "title": "Sub Window",
        "url": "index.html",
        "height": 1000
      }
    ],
...(snip)

ここで設定した label で、各ウィンドウの判別が行われる。

どちらも urlindex.html としているので、同じ画面を表示する。

フロントエンド実装

src/App.tsx:

初回起動時にイベントを listen.

各種ボタンでそれぞれの対象に対してイベントを発行する。

import { useEffect, useState } from "react";
import "./App.css";
import { WebviewWindow, appWindow, getCurrent } from "@tauri-apps/api/window";
import { emit, listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api";

function App() {
  const currentWindow = getCurrent();
  const [receivedEvent, setReceivedEvent] = useState<Array<any>>([]);

  let initialized = false;

  {/* useEffect を使って `event` という名前のイベントを購読する */}
  useEffect(() => {
    if (!initialized) {
      let unlisten: any = undefined;
      (async () => {
        unlisten = await listen('event', (e: any) => {
          console.log(e);
          setReceivedEvent((prev) => [...prev, e])
        });
      })();

      initialized = true;

      return () => {
        if (unlisten) {
          unlisten();
        }
      };
    }
  }, []);

  return (
    <div className="container">
      <h1>Welcome to Tauri!</h1>

      {/* getCurrent で、現在の WebviewWindow オブジェクトが取得できる */}
      <p>This window is: `{currentWindow.label}`</p>

      <h2>フロントエンドからのイベント送信</h2>

      <button onClick={() => {
        emit('event', { message: `${currentWindow.label}からのイベント` })
      }}>表示中の全ウィンドウへイベント送信</button>
      <button onClick={() => {
        appWindow.emit('event', { message: `${currentWindow.label}からのイベント` })
      }}>自分自身へイベント送信</button>
      <button onClick={() => {
        const mainWindow = new WebviewWindow('main');
        mainWindow.emit('event', { message: `${currentWindow.label}からのイベント` })
      }}>mainへのイベント送信</button>
      <button onClick={() => {
        const subWindow = new WebviewWindow('sub');
        subWindow.emit('event', { message: `${currentWindow.label}からのイベント` })
      }}>subへのイベント送信</button>

      <h2>バックエンドからのイベント送信</h2>

      <button onClick={() => {
        invoke('to_all', {});
      }}>表示中の全ウィンドウへイベント送信</button>
      <button onClick={() => {
        invoke('to_main', {});
      }}>mainへのイベント送信</button>
      <button onClick={() => {
        invoke('to_sub', {});
      }}>subへのイベント送信</button>

      <ul>
        {receivedEvent.map((e) => <li key={e.id}>{JSON.stringify(e)}</li>)}
      </ul>
    </div>
  );
}

export default App;

バックエンド実装

src-tauri/src/main.rs:

フロントエンドから to_all, to_main, to_sub のコマンドを呼び出すようにしたので、 それらコマンドを実装し、それぞれ対象のウィンドウにイベントを発行するように実装。

// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use serde::Serialize;
use tauri::{AppHandle, Manager};

#[derive(Clone, Serialize)]
struct Payload {
    message:  String,
}

#[tauri::command]
fn to_all(app_handle: AppHandle) {
    app_handle.emit_all("event", Payload { message: "バックエンドからのイベント".to_string() }).unwrap();
}

#[tauri::command]
fn to_main(app_handle: AppHandle) {
    app_handle.emit_to("main", "event", Payload { message: "バックエンドからのイベント".to_string() }).unwrap();
}

#[tauri::command]
fn to_sub(app_handle: AppHandle) {
    app_handle.emit_to("sub", "event", Payload { message: "バックエンドからのイベント".to_string() }).unwrap();
}

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            to_all,
            to_main,
            to_sub])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

動作確認

各ボタンをポチポチすると、ウィンドウ下部の受信イベント表示欄に受信したイベントが表示されていく。 良さそう。

実行した画面

以上。

参考資料

2024年6月24日月曜日

Tauri で Window 配置の復元をする

今回は、前回プログラム終了時のウィンドウ配置を復元するやつを試す。

plugins-workspace/plugins/window-state at v1 · tauri-apps/plugins-workspace を導入するだけ。

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0
  • delete-default-event をベースに作業をしたので、そのディレクトリとの差分を取ってください

実装

実装手順は以下の通り。

  1. バックエンドにプラグインへの依存を追加
  2. Tauri Builder にプラグインを登録

これだけ。

バックエンドにプラグインへの依存を追加

src-tauri/Cargo.tomldependencies セクションに、以下を追加。

tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }

Tauri Builder にプラグインを登録

src-tauri/src/main.rstauri::Builder::default() に、 Store プラグインを登録する定義を追加。

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_window_state::Builder::default().build()) # この行を追加
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

これで、前回終了時のウィンドウ配置を復元してくれるようになる。

マニュアルで記録・レストアする機能もあるみたいだが、それは必要になった時に調べることとする。

以上。

参考資料

Tauri の Store を使ってデータの永続化を行う

今回は、設定やキャッシュなどに使える、データの永続化を行う。

plugins-workspace/plugins/store at v1 · tauri-apps/plugins-workspace を利用すると簡単に実現できる。

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0
  • delete-default-event をベースに作業をしたので、そのディレクトリとの差分を取ってください

実装

実装手順は以下の通り。

  1. バックエンドにプラグインへの依存を追加
  2. フロントエンドにプラグインへの依存を追加
  3. Tauri Builder にプラグインを登録
  4. Store を使って永続化

バックエンドにプラグインへの依存を追加

src-tauri/Cargo.tomldependencies セクションに、以下を追加。

tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }

フロントエンドにプラグインへの依存を追加

プロジェクトルートで以下を実行。今回は npm を利用。

npm add https://github.com/tauri-apps/tauri-plugin-store#v1

Tauri Builder にプラグインを登録

src-tauri/src/main.rstauri::Builder::default() に、 Store プラグインを登録する定義を追加。

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_store::Builder::default().build()) # この行を追加
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Store を使うための事前準備はこれだけ。

後は、使うための実装をしていく。

Store を使って永続化

テキストフィールドの値をストアに保存し、次回起動時に復元するようにしてみる。

また、バックエンドは前回起動日時をストアに記録し、起動時に標準出力へ表示する。

フロントエンド

import { useEffect, useState } from "react";
import reactLogo from "./assets/react.svg";
import "./App.css";

import { Store } from "tauri-plugin-store-api";

function App() {
  const [greetMsg, setGreetMsg] = useState("");
  const [name, setName] = useState("");

  useEffect(() => {
    (async () => {
      // ストアから情報を抽出して存在するなら表示
      const store = new Store("greet.json");
      const name = await store.get("name");
      setName(name?.value);
    })();

  }, []);

  return (
    <div className="container">
      <h1>Welcome to Tauri!</h1>

      <div className="row">
        <a href="https://vitejs.dev" target="_blank">
          <img src="/vite.svg" className="logo vite" alt="Vite logo" />
        </a>
        <a href="https://tauri.app" target="_blank">
          <img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
        </a>
        <a href="https://reactjs.org" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>

      <p>Click on the Tauri, Vite, and React logos to learn more.</p>

      <form
        className="row"
        onSubmit={(e) => {
          e.preventDefault();
          setGreetMsg(`Hello, ${name}! You've been greeted from Rust!`);
        }}
      >
        <input
          id="greet-input"
          onChange={ async (e) => {
            setName(e.currentTarget.value)

            // ストアに保存
            const store = new Store("greet.json");
            await store.set("name", { value: e.currentTarget.value });
          }}
          placeholder="Enter a name..."
          value={name}
        />
        <button type="submit">Greet</button>
      </form>

      <p>{greetMsg}</p>
    </div>
  );
}

export default App;
  • new Store("greet.json") で、アプリローカルディレクトリ(~/.local/share/<アプリのidentify>) に greet.json という名前のストアを作成
  • store.set(<キー>, <バリュー>) で、キーに対してバリューを保存
  • store.save() でファイルへ書き出し
  • store.get(<キー>) で保存したバリューを取得

バックエンド

// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::time::SystemTime;

use serde_json::json;
use tauri_plugin_store::StoreBuilder;

fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_store::Builder::default().build())
        .setup(|app| {
            // ストア情報をロード
            let mut store = StoreBuilder::new(app.handle(), "greet_backend.json".parse()?).build();
            let _ = store.load();

            // store のキー `launch` から前回起動時刻を取得し、表示
            let launch = store.get("launch");
            println!("previous launch: {:?}", launch);

            // 現在時刻をストアに保存
            let now = SystemTime::now();
            let _ = store.insert("launch".to_string(), json!(now));
            let _ = store.save();

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

詳細は setup 内のコメントを参照してください。

初回起動時は空・None が表示され、閉じて開きなおすと前回入力した値や、前回の起動時刻が表示される。

うん、できていそう。以上。

参考資料

2024年6月19日水曜日

Vim でスネークケース→キャメルケース変換

Vim でのスネークケース→キャメルケース変換作業をしたので、今回やった方法をメモ。

  1. 検索で、 /\C_[A-Z] として対象の変数までカーソルを持っていく
  2. どっちのキャメルケースにしたいかによって以下 2 つのどちらかを入力
    • ロウワーキャメル: qqviwuve:s/\v_(.)/\u\1/g<Enter>q
    • アッパーキャメル: qqviwuvUve:s/\v_(.)/\u\1/g<Enter>q
  3. 以降、 n して @q で確認しながら変換していく
  • qq: レジスタ q でマクロを記録開始
  • viwu: 変数が 1 ワードなので、全てをいったん小文字に変換
  • viwu: 変数が 1 ワードなので、全てをいったん小文字に変換
  • vu: アッパーキャメルケースにしたいときに追加、カーソルのある位置の文字を大文字に置換
  • ve: 変数全体をヴィジュアルモードで選択
  • :s/\v_(.)/\u\1/g<Enter>: キャメルケースになるようにアンダースコア直後の文字を大文字に変換
    • \v_(.): 一般的な(?)正規表現で、「アンダースコアと、そのひとつ後ろの文字」をヒットさせる ヒットしたモノの中の、「ひとつ後ろの文字」を、() で括り、置換の際に利用する
    • \u\1 : 検索時に () で括った個所のひとつ目を、大文字に変換して置換
  • q: マクロ記録終了
  • @q: q レジスタに記録したマクロを実行

以上。

参考資料

2024年6月15日土曜日

Tauri で各プラットフォーム向けのアイコンを生成する

掲題の通り。

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0
  • この回 で作成した delete-default-event をベースに作業をしたので、そのディレクトリとの差分を取ってください

ベースのアイコンを生成

TIL/svg/icon/myicon.svg at master · mikoto2000/TIL に SVG 形式の自分のアイコンがあるので、これを 1024x1024 の背景透過 png へ変換する。

※この作業は Windows の ImageMagick を使って実行した

magick.exe convert -background transparent .\myicon.svg -resize 1024x1024 .\myicon.png

これで生成した png を、プロジェクトルートの一つ上のディレクトリへ配置した。

png を基に各プラットフォーム向けのアイコンを生成

プロジェクトルートの一つ上に配置したアイコンを、 npm run tauri icon コマンドに渡すと、各プラットフォーム向けのアイコンを生成してくれる。

$ npm run tauri icon ../myicon.png

> tauri-delete-default-event@0.0.0 tauri
> tauri icon ../myicon.png

        Appx Creating StoreLogo.png
        Appx Creating Square30x30Logo.png
        Appx Creating Square44x44Logo.png
        Appx Creating Square71x71Logo.png
        Appx Creating Square89x89Logo.png
        Appx Creating Square107x107Logo.png
        Appx Creating Square142x142Logo.png
        Appx Creating Square150x150Logo.png
        Appx Creating Square284x284Logo.png
        Appx Creating Square310x310Logo.png
        ICNS Creating icon.icns
         ICO Creating icon.ico
         PNG Creating 128x128@2x.png
         PNG Creating icon.png
         PNG Creating 32x32.png
         PNG Creating 128x128.png

src-tauri/icons に生成されたファイルが格納される。

生成後のアイコンたち

アプリを起動すると、タスクバーのアイコンが今回せいせいした物に代わっているのがわかる。

以上。

参考資料

Tauri でフロントエンド↔バックエンドでイベントを送信しあう

今回は、フロントエンドとバックエンドでイベントの送受信を行う。 Events | Tauri Apps の内容。

実行画面

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0
  • 前々々回 作成した delete-default-event をベースに作業をしたので、そのディレクトリとの差分を取ってください
  • ウィンドウについてはまだ知らないので、グローバルイベントのみ作ってみる

実装

以下の実装を行う。

  1. フロントエンドのボタン押下時に greet イベントを送信
  2. バックエンドは、 greet イベントをリッスン
  3. バックエンドは、 greet イベントが発火した 2 秒後に、 greet_ended イベントを発火する
  4. フロントエンドは、 greet_ended イベントをリッスン
  5. フロントエンドは、 greet_ended イベントが発火したら、受信したメッセージを画面に表示する

フロントエンド

listen でイベントを待ち受け、 emit でイベントを送信する。

...(snip)
import { emit, listen } from '@tauri-apps/api/event'
...(snip)
  const [greetEndedMsg, setGreetEndedMsg] = useState("");

  {/* `greet_ended` イベントをリッスン */}
  listen('greet_ended', (event) => {
    setGreetEndedMsg(JSON.stringify(event));
  })
...(snip)
      <form
        className="row"
        onSubmit={(e) => {
          e.preventDefault();
          setGreetMsg(`Hello, ${name}! You've been greeted from Rust!`);

          {/*ボタン押下で `greet` イベントを emit */}
          emit('greet', { greet_message: name })
        }}
      >
        <input
          id="greet-input"
          onChange={(e) => setName(e.currentTarget.value)}
          placeholder="Enter a name..."
        />
        <button type="submit">Greet</button>
      </form>

      <p>{greetMsg}</p>

      {/* 受信した `greet_ended` イベントのメッセージを表示 */}
      <p>{greetEndedMsg}</p>
...(snip)

バックエンド

フロントエンドから受け取るイベントのペイロードは、「JSON 文字列」。

フロントエンドへ送るイベントのペイロードは、「serde::Serialize を付与した構造体」。

こちらもフロントエンドと似たような形で、 App#listen_global でグローバルイベントを待ち受け、 AppHandle#emit_all でイベントをグローバルに送信する。

...(snip)
use std::{thread, time::Duration};

use tauri::Manager;

// フロントエンドから受信したメッセージをデシリアライズするための構造体
#[derive(Clone, Debug, serde::Deserialize)]
struct GreetPayload {
  greet_message: String,
}

// フロントエンドへ送信するメッセージをシリアライズするための構造体
#[derive(Clone, Debug, serde::Serialize)]
struct GreetEndedPayload {
  greet_ended_message: String,
}

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            let app_handle = app.handle();

            // イベント `greet` の listen 開始
            // listen を止めたいことがある場合、
            // 戻り値の id を使って `app.unlisten(id)` とする
            let _id = app.listen_global("greet", move |event| {
                let emit_payload_json = event.payload().unwrap();
                println!("EmitPayloadJson: {:?}", emit_payload_json);

                // JSON 文字列で受け取るので、構造体へデシリアライズ
                let emit_payload = serde_json::from_str::<GreetPayload>(&emit_payload_json);
                println!("GreetPayload: {:?}", emit_payload);

                // 構造体からメッセージを抜き出す
                let greet_message = emit_payload.unwrap().greet_message;
                println!("GreetPayload.greet_message: {:?}", greet_message);

                // 2 秒後にイベント `greet_ended` を発火
                thread::sleep(Duration::from_millis(2000));
                let greet_message = format!("greet end by message: {}", greet_message);
                let app_handle = app_handle.clone();
                app_handle.emit_all("greet_ended", GreetEndedPayload {
                    greet_ended_message: greet_message
                }).unwrap();
            });
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

以上。

参考資料

Tauri のフロントエンド(TypeScript)からバックエンド(Rust)を呼び出す

今回は、フロントエンド(TypeScript)からバックエンド(Rust)を呼び出すための仕組みである、 Command の使い方を勉強する。

実行画面

前提

  • 開発環境構築は済んでいるものとする
  • Tauri:
    • tauri-cli: 1.5.14
    • rust: 1.78.0
    • node: v22.2.0
  • 前々回 作成した delete-default-event をベースに作業をしたので、そのディレクトリとの差分を取ってください

実装 その1 - 引数・戻り値の無いコマンド

コマンドを実装(バックエンド)

コマンドの実体である関数を定義(バックエンド)

src-tauri/src/main.rs:

...(snip)
#[tauri::command]
fn implemented_command_function() {
    println!("Called implementedCommandFunction!!!!!");
}
...(snip)

戻り値のないコマンドを定義した。やるべきことは以下 2 つ。

  1. コマンド処理関数の実装
  2. #[tauri::command] アノテーションの付与

これでコマンド定義の完了。

コマンドをアプリに登録(バックエンド)

定義したコマンドを、アプリが使えるようにするため、登録処理を行う。

src-tauri/src/main.rs:

...(snip)
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![implemented_command_function])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

invoke_handler の行を追加。

これで、アプリがコマンドを呼び出す準備ができた。

コマンドを呼び出す(フロントエンド)

src/App.tsx:

...(snip)
import { invoke } from '@tauri-apps/api/tauri'
...(snip)
  function callImplementedCommandFunction() {
    invoke('implemented_command_function');
  }
...(snip)
      <div>
        <label>
          Call implemented_command_function command:
          <button
            onClick={() => {callImplementedCommandFunction()}}>
            call
          </button>
        </label>
      </div>
...(snip)

invoke でコマンドを呼び出せる。

ボタンを追加し、イベントハンドラを登録し、そこから invokeimplemented_command_function を呼び出すようにした。

動作確認 その1 - 引数・戻り値の無いコマンド

今回は、コマンドライン引数が絡まないので、dev コマンドで開発用サーバーを立ち上げ、動作確認を行う。

$ npm run tauri dev

> tauri-delete-default-event@0.0.0 tauri
> tauri dev

     Running BeforeDevCommand (`npm run dev`)

> tauri-delete-default-event@0.0.0 dev
> vite


  VITE v5.2.13  ready in 103 ms

    Local:   http://localhost:1420/
    Network: use --host to expose
        Info Watching /workspaces/TIL/tauri/1.0.0/CallingRustFromTheFrontend/tauri-delete-default-event/src-tauri for changes...
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.11s
実行画面

call ボタンを押下すると、以下メッセージがターミナルに表示される。

Called implemented_command_function!!!!!

OK っぽい。

実装 その2 - 引数・戻り値のあるコマンド

コマンドを実装(バックエンド)

引数・戻り値がある以外は「その1」と同様。

src-tauri/src/main.rs:

...(snip)
#[tauri::command]
fn sum(x:i64, y: i64) -> i64 {
    return x + y;
}
...(snip)
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            implemented_command_function,
            sum])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

これで、アプリがコマンドを呼び出す準備ができた。

コマンドを呼び出す(フロントエンド)

以下 3 点を理解しつつ追加していく。

  1. 引数は、バックエンドで定義した仮引数名をパラメーターとして持つ JSON オブジェクトとして定義し、 invoke へ渡す
  2. フロントエンドで使う仮引数名は、「ローワーキャメルケース」で定義する(Tauri が変換処理を行う)
  3. invoke の戻り値は、 then の引数として受け取る。

src/App.tsx:

  const [sumResult, setSumResult] = useState("");
...(snip)
  function callSum() {
    invoke('sum', {xForSum: 1, yForSum: 2})
      .then((result) => setSumResult(result));
  }
...(snip)
      <div>
        <label>
          Execute 1 + 2: {sumResult}
          <button
            onClick={() => {callSum()}}>
            call
          </button>
          <button
            onClick={() => setSumResult("")}>
            Clear
          </button>
        </label>
      </div>
...(snip)

動作確認 その2 - 引数・戻り値のあるコマンド

今回追加した call ボタンを押下すると、Rust で計算された結果が表示される。

実装 その3 - 非同期コマンド

コマンドを実装(バックエンド)

関数に async 修飾子が付く以外は「その1」と同様。

今回は簡便にカウンタを用意するために static mut なグローバル変数と unsafe を使って実装。

src-tauri/src/main.rs:

...(snip)
static mut CALL_NUMBER : u64 = 0;
#[tauri::command]
async fn async_command() -> String {
    println!("Called asyncCommand!!!!!");
    let call_number = unsafe { CALL_NUMBER }
    ;
    unsafe { CALL_NUMBER += 1 }
    thread::sleep(Duration::from_millis(2000));
    return format!("Called asyncCommand {}.", call_number)
}
...(snip)
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            implemented_command_function,
            sum,
            async_command])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

これで、アプリがコマンドを呼び出す準備ができた。

コマンドを呼び出す(フロントエンド)

普通の戻り値ありのものと同じように呼び出せば OK.

src/App.tsx:

  const [asyncCommandResult, setAsyncCommandResult] = useState([]);
...(snip)
  function callAsyncCommand() {
    invoke('async_command')
      .then((result) => {
        setAsyncCommandResult((x) => [...x, result])
      })
  }
...(snip)
      <div>
        <label>
          Execute asyncCommand:
          <button
            onClick={() => {callAsyncCommand()}}>
            call
          </button>
          <button
            onClick={() => setAsyncCommandResult([])}>
            Clear
          </button>
        </label>
        <ol>
          {
            asyncCommandResult.map((e) => <li>{e}</li>)
          }
        </ol>
      </div>
...(snip)

動作確認 その3 - 非同期コマンド

今回追加した call ボタンを押下すると、2 秒後にリストに追加される。 async によりメインとは別スレッドで処理が行われるため、 UI がブロックされない事も確認できる。

実装 その4 - エラーの可能性のあるコマンド

コマンドを実装(バックエンド)

引数が Result になる以外は「その1」と同様。

src-tauri/src/main.rs:

...(snip)
#[tauri::command]
fn success_or_failed(success: bool) -> Result<String, String> {
    println!("Called successOrFailed!!!!!");
    if success {
        Ok("Success!".into())
    } else {
        Err("Failed!".into())
    }
}
...(snip)
fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            implemented_command_function,
            sum,
            async_command,
            success_or_failed])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

これで、アプリがコマンドを呼び出す準備ができた。

コマンドを呼び出す(フロントエンド)

よくある API と同じように、 thencatch で処理を分けて記述する。

src/App.tsx:

  const [successOrFailedMessage, setSuccessOrFailedMessage] = useState("");
...(snip)
  function callSuccessOrFailed(success) {
    invoke('success_or_failed', {success: success})
      .then((result) => {
        setSuccessOrFailedMessage(result)
      })
      .catch((error) => {
        setSuccessOrFailedMessage(error)
      })
  }
...(snip)
      <div>
        <label>
          Execute successOrFailed:
          <button
            onClick={() => {callSuccessOrFailed(true)}}>
            call success
          </button>
          <button
            onClick={() => {callSuccessOrFailed(false)}}>
            call failed
          </button>
          <button
            onClick={() => setSuccessOrFailedMessage("")}>
            Clear
          </button>
        </label>
        <p>{successOrFailedMessage}</p>
      </div>
...(snip)

動作確認 その4 - エラーの可能性のあるコマンド

バックエンド側で Ok が返却されると then の処理が、 Err が返却されると catch の処理が実行されることが確認できる。

他にも Window や AppHandle, State などがあるようだが、 そもそもそれらが何なのか今は分からないためここまでとする。

以上。

参考資料