LoginSignup
2
5

Rust初心者がノリでTauri製デスクトップアプリを作ってみた

Last updated at Posted at 2023-12-04

ジョブカン事業部のアドベントカレンダー5日目です。

今回は、Rustの基本構文だけを軽く勉強した状態でRustでデスクトップアプリを作ってハマった点について書いてみたいと思います。

作成したアプリ

今回作成したアプリは、英語の文章を打ち込むと単語や文章単位でDeepL APIで翻訳して日本語を表示してくれるという自分用の英語勉強アプリです。
英語勉強の際に小説などの文章を写経しながら、わからないフレーズを翻訳しながら写していくような使い方ができます。

英語版wikipediaのRustページ冒頭を入れてみた例
https://en.wikipedia.org/wiki/Rust_(programming_language)
スクリーンショット 2023-11-27 0.14.49.png

技術選定

今回はデスクトップアプリを作成しようと思っていたので、最初に候補に上がったのは以前使ったことがあるElectronでした。
Electronで作成しても良かったのですが、かねてよりRustを使ってみたい思っていたため、Rustを採用しているフレームワークであるTauriを使ってみることにしました。

Electronについて

ElectronはJavaScript, HTML, CSSでデスクトップアプリを作成できるフレームワークで、VSCodeなどにも採用されています。
UI部分がChromiumの上で動くので一般的なウェブアプリのフロントエンドと同じように実装できます。
バックエンド(厳密にはアプリ内のメインプロセス)はNode.js上で動作しておりそこからPC内のリソースなどにアクセスすることができます。

Tauriについて

TauriはElectronと似たようなフレームワークでUI部分は同じくフロントエンドの技術を使えますが、ChromiumではなくOSが提供するWebViewを使用します。これによりアプリのサイズを小さくすることができます。
メインプロセスにはNode.jsではなくRustを使用しており、今回Tauriを採用した理由です。

Rustについて

Rustは、C言語等と同じようにネイティブの実行バイナリを生成できるコンパイル言語です。C/C++に代わる言語を目指しており、実際にLinuxやWindowsで採用され始めているような話を耳にします。
Rustの特徴としてはコンパイラが厳格で、「ボローチェッカー」(borrow checker)という仕組みによってメモリの安全性が保証されています。

開発

ここからは実際の開発の流れに沿って、躓いたところをメインにご紹介できればと思います。

Tauriプロジェクト作成

tauriをインストールして下記コマンドを実行すると対話形式で使用するパッケージマネージャーや言語などを訊かれるため、それに答えていくことでプロジェクトを生成してくれます。
私はUI部分にReact+TypeScriptを選んだのですが、本当に選んだだけでセットアップしてくれます。
ElectronのときはTypeScript化時に型定義周りで苦労した記憶があるので嬉しいポイントでした。

$ yarn create tauri-app

アプリ起動

$ yarn install
$ yarn tauri dev

上記コマンドを実行することでアプリを起動することができます。

このタイミングだったか少し後だったか忘れてしまったのですが、序盤にフロントエンド側でglobalが無いといったエラーが出て小一時間悩まされました。
最終的にはindex.htmlに下記を追記することで解決しました。

index.html
<script>
  const global = globalThis;
</script>

UI(React)とメインプロセス(Rust)の通信

#[tauri::command]
fn main_proc_method(params: &str) -> Result<String, String> {
    // UIからのリクエストを処理して応答を返す
    Ok("hoge".to_string())
}

// mainでinvoke_handlerにmain_proc_methodを登録
fn main() {
    tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![main_proc_method])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

上のようにRust側でリクエストを処理するメソッド(tauri::command)を用意しておき、UI側からのリクエスト送信はinvokeを呼び出す形になります。

invoke('main_proc_method', { params: hoge }).then((response: any) => {
  // 応答を処理
});

#[tauri::command]絡みで怒られる

ここまでは順調に進んできたのですが、英文をDeepL APIに投げる処理を書いたところ#[tauri::command]future returned by `translate_into_jananese` is not `Send`と怒られてしまいました。

#[tauri::command]
async fn translate_into_jananese(phrase: &str) -> Result<String, String> {
    // DeepLに翻訳を投げたりする処理
}

ちゃんとRustを学んでいれば適切に処置できたと思うのですが、このときはなぜ怒られているか全く分からず、GoogleやChatGPTをさまよった結果、asyncメソッドにしているのが悪いと考えtranslate_into_jananeseからasyncを外し実装全体を別のasyncメソッドに移動することにしました。
Rustでは非同期処理を行うときに自分でランタイムを用意する必要があるようで、tokioを使って実装しています。

#[tauri::command]
fn translate_into_jananese(phrase: &str) -> Result<String, String> {
    let runtime = tokio::runtime::Runtime::new().unwrap();
    runtime.block_on(async_translate_into_jananese(phrase))
}

async fn async_translate_into_jananese(phrase: &str) -> Result<String, String> {
    // DeepLに翻訳を投げたりする処理
}

実装当時はasyncメソッドにしていることが良くないと判断してしまったのですが、この記事を書きながら試したところ原因はasyncではないことがわかりました。
おそらくですが、後述のキャッシュ周りの処理でMutexを使っていたのがいけなかったようで、上記対処で結果的に治ってしまった形のようです。

グローバル変数のコンパイルが通らない

翻訳の表示はマウスオーバーをトリガーにして行われるため、何度か同じ単語のリクエストがRustに飛んでくることがあります。DeepL APIにはリクエストの上限がありますし、毎回DeepLで翻訳していては動作が遅くなってしまうためRust側でキャッシュをすることにしたのですが、この実装に一番苦労しました。

今回はasyncメソッドで使用する上にキャッシュとして使うため下記の特性を持つ変数を作る必要があります。

  • グローバル変数
  • asyncメソッドから呼び出せる
  • 変更できる(Mutable)

staticやRefCellなど様々な組み合わせを試したのですが、上記をすべて満たすものはなかなか作れず、特にMutableのままグローバル変数にするのが難しかったです。
試行錯誤した結果、最終的に下記のような実装になりました。

Lazyはstatic変数を遅延初期化するために必要で、Mutexはasyncメソッドから安全にアクセスするために必要なイメージです。

use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;

// 定義
static TRANSLATE_CACHE: Lazy<Mutex<HashMap<String, String>>> = Lazy::new(|| {
    Mutex::new(HashMap::new())
});

// 変数の使用
async fn async_translate_into_jananese(phrase: &str) -> Result<String, String> {
    let mut cache = TRANSLATE_CACHE.lock().unwrap();
    
    // キャッシュに引っかかったらそれを返す
    if let Some(ja_phrase) = cache.get(phrase).cloned() {
        println!("cache hit! {} -> {}", phrase, ja_phrase);
        return Ok(ja_phrase);
    }

    // ︙
}

まとめ

今回Electronと似たTauriを使ってみましたが、個人的にはElectronよりシンプルに書ける部分が多くて書きやすかったです。
RustについてもC++を書いていた頃の気持ちを久々に思い出し懐かしくなりました。事前に聞いていた通り型周りやコンパイラが強力で安心感がありました。

Rustはまだまだ勉強を始めたところなので分からない部分が多かったのですが、非常に書きやすかったので勉強を続けて個人的に何かを作る際に積極的に採用していきたいと思います。

ソースコード

だいぶ雑に書いているので参考になるかわかりませんが、Rust側とReact側のメインのコードです。

完成したコード(Rust+React)
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use reqwest::Client;
use serde::Serialize;
use serde::Deserialize;
use std::env;
use dotenv::dotenv;
use std::collections::HashMap;
use std::sync::Mutex;
use once_cell::sync::Lazy;
use std::fs;
use std::fs::File;
use std::io::{self, Read, Write, BufReader};

static TRANSLATE_CACHE: Lazy<Mutex<HashMap<String, String>>> = Lazy::new(|| {
    Mutex::new(HashMap::new())
});

#[derive(Debug, Serialize, Deserialize)]
pub struct Translation {
    detected_source_language: String,
    text: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TranslationResponse {
    translations: Vec<Translation>,
}

#[tauri::command]
fn translate_into_jananese(phrase: &str) -> Result<String, String> {
    let runtime = tokio::runtime::Runtime::new().unwrap();
    runtime.block_on(async_translate_into_jananese(phrase))
}

async fn async_translate_into_jananese(phrase: &str) -> Result<String, String> {
    let mut cache = TRANSLATE_CACHE.lock().unwrap();

    // キャッシュに引っかかったらそれを返す
    if let Some(ja_phrase) = cache.get(phrase).cloned() {
        println!("cache hit! {} -> {}", phrase, ja_phrase);
        return Ok(ja_phrase);
    }

    // キャッシュになかったらDeepLを使って翻訳する
    let deepl_auth_key = env::var("DEEPL_AUTH_KEY").expect("DEEPL_AUTH_KEY is not defined");
    let client = Client::new();
    let url = "https://api-free.deepl.com/v2/translate";
    let params = [
        ("text", phrase),
        ("source_lang", "EN"),
        ("target_lang", "JA"),
    ];
    let result = client
        .post(url)
        .header("Authorization", format!("DeepL-Auth-Key {}", deepl_auth_key))
        .form(&params)
        .send()
        .await;
    match result {
        Ok(response) => {
            let res = parse_response(response).await?;
            println!("{:?}", res);
            println!("{}", res.translations[0].text);
            if res.translations.len() > 0 {
                let ja_phrase = res.translations[0].text.clone();
                cache.insert(phrase.to_string(), ja_phrase.clone());
                return Ok(ja_phrase);
            }
            return Err("no translation".to_string());
        },
        Err(_) => return Err("post error".to_string())
    }
}

#[tauri::command]
fn save(file_name: &str, json_str: &str) {
    let runtime = tokio::runtime::Runtime::new().unwrap();
    runtime.block_on(async_save(file_name, json_str))
}

async fn async_save(file_name: &str, json_str: &str) {
    let mut file = File::create(file_name);
    match file {
        Ok(mut f) => {
            match f.write_all(json_str.as_bytes()) {
                Ok(_) => println!("save success"),
                Err(_) => println!("save error")
            }
        },
        Err(_) => println!("file create error")
    }
}

#[tauri::command]
fn load(file_name: &str) -> Result<String, String> {
    let runtime = tokio::runtime::Runtime::new().unwrap();
    runtime.block_on(async_load(file_name))
}

async fn async_load(file_name: &str) -> Result<String, String> {
    let content = fs::read_to_string(file_name);
    match content {
        Ok(f) => {
            return Ok(f);
        },
        Err(_) => println!("file read error")
    }
    
    Err("unknown error".to_string())
}

async fn parse_response(response: reqwest::Response) -> Result<TranslationResponse, String> {
    let res = response.json::<TranslationResponse>().await;
    match res {
        Ok(body) => Ok(body),
        Err(_) => Err("json error".to_string())
    }
}

fn main() {
    dotenv().ok();
    tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![translate_into_jananese, save, load])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

import { useState, useEffect, useRef } from "react";
import { invoke } from "@tauri-apps/api/tauri";
import { Editor, EditorState, Modifier, CompositeDecorator, convertToRaw, convertFromRaw } from "draft-js";
import "draft-js/dist/Draft.css";
import "./App.css";

const TRANSLATE_BLOCK_ENTITY_TYPE = 'TRANSLATE_BLOCK_ENTITY';

const SpanEntity = (props: any) => {
  const phrase = `${props.children[0].props.text}`;
  const handleMouseEnter = () => {
    invoke('translate_into_jananese', { phrase: phrase }).then((translatedPhrase: any) => {
      props.setPhraseDetail({phrase: phrase, translatedPhrase: translatedPhrase});
    });
  };
  return <span className="translate-phrase" onMouseEnter={handleMouseEnter}>{phrase}</span>;
};

const spanEntityStrategy = (contentBlock: any, callback: any, contentState: any) => {
  contentBlock.findEntityRanges((character: any) => {
      const entityKey = character.getEntity();
      return entityKey !== null && contentState.getEntity(entityKey).getType() === TRANSLATE_BLOCK_ENTITY_TYPE;
  }, callback);
};


type EditorBlock = {
  key: string
  text: string
  height: number
  lastTranslatedText: string | null
}

function App() {
  const [fileName, setFileName] = useState('');
  const [editorEnable, setEditorEnable] = useState(false);
  const [phraseDetail, setPhraseDetail] = useState({phrase: '', translatedPhrase: ''});
  const [editorState, setEditorState] = useState(() => {
    const decorators = new CompositeDecorator([
      {
          strategy: spanEntityStrategy,
          component: (decoratorProps: any) => <SpanEntity {...decoratorProps} setPhraseDetail={setPhraseDetail} />
      }
    ]);
    return EditorState.createEmpty(decorators)
  });
  const editorRef = useRef<HTMLDivElement>(null);
  const [blocks, setBlocks] = useState([] as EditorBlock[]);
  const [translatedMap, setTranslatedMap] = useState({} as { [key: string]: string });
  const [lastTranslatedMap, setLastTranslatedMap] = useState({} as { [key: string]: string });

  useEffect(() => {
    setEditorEnable(true);
  }, []);

  useEffect(() => {
    let heightMap: { [key: string]: number } = {};

    let elms = editorRef.current?.querySelectorAll('.public-DraftEditor-content > div > div') || [];
    for (var i = 0; i < elms.length; i++) {
      let offset_key = elms[i].getAttribute('data-offset-key');
      if ( offset_key != null) {
        let key = offset_key.split('-').shift();
        heightMap[key || ''] = elms[i].getBoundingClientRect().height;
      }
    }

    const contentState = editorState.getCurrentContent();
    const blockArray = contentState.getBlocksAsArray();
    setBlocks(blockArray.map((block) => {
      let key = block.getKey();
      let text = block.getText();
      console.log(key);
      console.log(heightMap[key]);

      return {
        key: key,
        text: text,
        height: heightMap[key],
        lastTranslatedText: lastTranslatedMap[key],
      }
    }));
  }, [editorState])

  const toggleTranslatePhrase = () => {
    console.log('toggleTranslatePhrase');
    const content = editorState.getCurrentContent();
    const selection = editorState.getSelection();

    if (!selection.isCollapsed()) {
      const startKey = selection.getStartKey();
      const startOffset = selection.getStartOffset();
      const blockWithEntityAtStart = content.getBlockForKey(startKey);
      const entityKeyAtStart = blockWithEntityAtStart.getEntityAt(startOffset);

      // 既にエンティティが適用されているか確認
      const alreadyHasEntity = entityKeyAtStart && content.getEntity(entityKeyAtStart).getType() === TRANSLATE_BLOCK_ENTITY_TYPE;

      let newContent;

      if (alreadyHasEntity) {
        // エンティティを取り除く
        newContent = Modifier.applyEntity(content, selection, null);
      } else {
        // エンティティを適用する
        const contentWithEntity = content.createEntity(TRANSLATE_BLOCK_ENTITY_TYPE, 'MUTABLE');
        const entityKey = contentWithEntity.getLastCreatedEntityKey();
        newContent = Modifier.applyEntity(contentWithEntity, selection, entityKey);
      }

      const newEditorState = EditorState.push(editorState, newContent, 'apply-entity');
      setEditorState(newEditorState);
    }
  };

  const save = () => {
    const contentState = editorState.getCurrentContent();
    const content = convertToRaw(contentState);
    invoke('save', { fileName: fileName, jsonStr: JSON.stringify(content) }).then(() => {
      console.log('saved');
    });
  }

  const load = () => {
    const contentState = editorState.getCurrentContent();
    const content = convertToRaw(contentState);
    const isEmpty = content.blocks.length == 0 || content.blocks.length == 1 && content.blocks[0].text == '';
    if (!isEmpty) {
      setPhraseDetail({phrase: 'Load', translatedPhrase: 'コンテンツがあるため、ロードできません'});
      return;
    }
    setPhraseDetail({phrase: 'Load', translatedPhrase: 'ロード中...'});

    invoke('load', { fileName: fileName }).then((jsonStr) => {
      const contentState = convertFromRaw(JSON.parse(`${jsonStr}`));
      const newEditorState = EditorState.push(editorState, contentState, 'apply-entity');
      setEditorState(newEditorState);
      setPhraseDetail({phrase: 'Load', translatedPhrase: 'ロード完了'});
    });
  }

  return (
    <div className="container">
      <div className="phrase-detail">
        <div>{phraseDetail.phrase}</div>
        <div>{phraseDetail.translatedPhrase}</div>
      </div>
      <div className="controll-area">
      <input className="fileNameField" value={fileName} onChange={(e) => setFileName(e.target.value)} type="text" autoComplete="off" />
        <div>
          <button onClick={toggleTranslatePhrase}>Translate</button>
          <button onClick={save}>Save</button>
          <button onClick={load}>Load</button>
        </div>
      </div>
      <div className="clearfix"></div>
      {editorEnable && (
        <div ref={editorRef} className="all-editor">
          <Editor editorState={editorState} onChange={setEditorState} />
          <div className="translate-area-root">
            <div className="translate-content">
              <div>
                {blocks.map((block) => {
                  return (
                    <div style={{height: block.height}}>
                      <span
                        className="translated-block-button"
                        onClick={() => {
                          invoke('translate_into_jananese', { phrase: block.text }).then((translatedPhrase: any) => {
                            setTranslatedMap((prev) => {
                              prev[block.key] = translatedPhrase;
                              return prev;
                            });
                            setLastTranslatedMap((prev) => {
                              prev[block.key] = block.text;
                              return prev;
                            });
                            // 配列を作り直して再描画
                            setBlocks((prev) => {
                              var next = [...prev];
                              for (let i = 0; i < next.length; i++) {
                                if (next[i].key == block.key) {
                                  next[i].lastTranslatedText = block.text;
                                  break;
                                }
                              }
                              return next;
                            });
                          });
                        }}
                      >
                        {block.lastTranslatedText == block.text ? '' : '*'}翻訳
                      </span>
                      {translatedMap[block.key]}
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

export default App;

おわりに

DONUTSでは新卒中途問わず積極的に採用活動を行っています。
詳細はこちらをご確認ください。

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5