🦀

【React + Rust】JSONファイルで管理できるカンバンのデスクトップアプリを作ってみた【Tauri】

2022/10/23に公開

はじめに

この記事では、Tauriというクロスプラットフォームのフレームワークを使用して、カンバンアプリのプロトタイプを作った話をしていきます。

日々更新しているので、記事を書いた時のコードと大きく変わっている可能性があります。

また、話すこと話さないことをまとめたので、それを踏まえてご覧ください。

話すこと
・Tauriのざっくりとした紹介
・アプリ全体の流れ(実装を抜粋)
・大変だったこと
・Tauriを使ってみた所感

話さないこと
・環境構築
・各言語の詳しい説明

コードは一部を抜粋して紹介していますので、気になる人はぜひgithubへ!
https://github.com/koichi-menta/alone-kanban

モチベーション

エンジニアという仕事をしていると日々タスクを管理することがあり、カンバン方式を取り入れているプロジェクトも多いと思います。

ですが、カンバンを取り入れてないプロジェクトもあったり、チームのカンバンで管理する必要はないけど自分のタスクをカンバンで管理したいと思ったことはありませんか?

私はあります。

そんな時、外部サービスでタスク管理をしようと頭をよぎるのですが、会社が把握していない外部サービスで具体的な業務内容を書いて管理するのはどうなんでしょう…

何かあった時に怖いですよね…

ということで色々考えた結果、パソコンのローカルで完結するタスク管理アプリを自分で作れば良いんだ!と思いつきました。

作ったもの

ローカルで完結するデスクトップカンバンアプリのプロトタイプ
特長として、データの管理はjsonファイルを使っており、CRUDが動く度にjsonファイルを書き換えてタスクを管理しています。

使い方はよくあるカンバン方式で、ドラッグ&ドロップでタスクを移動していきます。

使用した技術

フレームワーク:Tauri
フロントエンド:React
バックエンド:Rust

Tauriとは

Webの技術でデスクトップアプリを作成できるのクロスプラットフォームフレームワークです。

https://tauri.app/

クロスプラットフォームと聞いてまず思い浮かぶのはElectronだと思いますが、今回は最近話題のTauriを選定してみました。

TauriはRust言語で作られており、Electronよりファイルサイズが小さいことが特徴らしいです。

また、フロントエンドの基盤としてElectronはChromiumを採用していますがTauriは採用しておらず、代わりにOSに備わっているWebViewをwry経由で呼び出すことで、セキュリティを向上させつつクロスプラットフォームを実現しているそうです。

wryは、クロスプラットフォームのレンダリングライブラリだそうです。

最近(2022/06/15)にバージョン1.0.0がリリースされて注目を集めており、Electronの代替になるのではないかと言われているみたいです。

対応フロントエンド

フロントエンドはたくさんのライブラリに対応しています。

メジャーどころですと、React、Next、Vue、Angular、Svelteなどが対応しており、プロジェクト作成時のテンプレートも用意されています。

また、ビルドツールとしてNode、Cargo、Next、Svelte、Viteなどに対応しています。

https://github.com/tauri-apps/create-tauri-app

対応バックエンド

現状、バックエンドはRustのみに対応しているみたいです

今後のロードマップとして、Go、Python、C++などに対応していく予定らしいです。

また、現在はデスクトップアプリケーションしか作成できませんが、これも将来的にはスマホアプリも作成できるようになるみたいです。

詳しくはTauriの公式サイトをチェックしてみてください。

フロントエンド実装

それではさっそく実装の内容に触れていきます。

フロントエンドはReactとTypeScriptで実装しており、ドラッグ&ドロップはSortable.jsというライブラリを使用しています。

状態管理はいたってシンプルです。

タスク名を入力するテキストの状態管理と、モーダルのフラグ管理、あとはtodoなどのセクション毎にuseStateで管理しています。

今回はtodo、in_progress、doneの3つを分けて作成しています。

これはドラッグ&ドロップの実装で使っているSortable.jsの影響でこのような管理方法にしています。

App.tsx
const [text, setText] = useState<string>("");
const [todo, setTodo] = useState<Task[]>([]);
const [inProgress, setInProgress] = useState<Task[]>([]);
const [done, setDone] = useState<Task[]>([]);
const [isOpen, setIsOpen] = useState<boolean>(false);

Sortable.jsは、propsのlistにuseStateのデータを渡して、setListにuseStateのsetを渡すだけでドラッグ&ドロップの状態管理が簡単にできます。

また、各セクションをgroup化することができ、この機能のおかげでtodoからdoneなどの移動を検知してくれるので、イベントの実装がかなりシンプルにできました。

ドラッグ&ドロップの実装はかなり大変そうだったので、このライブラリは大変助かりましたね。

App.tsx
<ReactSortable
  group="groupName"
  animation={200}
  list={todo}
  setList={setTodo}
  onEnd={(e) => {
    // タスクを動かしたときに発火
    // 移動前のセクションidと移動後セクションidと
    // 移動前のセクションにあるデータのindexをもとにデータを移動
    handleMoveTask(e.from.id, e.to.id, e.oldIndex);
  }}
  className="columnTasks"
  id="todo" // セクションを一意にする
  forceFallback={true}
>
  {/* useStateのデータをリスト表示 */}
  {todo.map((item) => {
    return (
      <div className="taskItem" key={item.id}>
        {item.name}
        <span
          className="deleteBtn"
          data-target="todo"
          onClick={(e) => handleDeleteTask(e, item.id)}
        >
          x
        </span>
      </div>
    );
  })}
</ReactSortable>

このReactSortableコンポーネントをtodo、in_progress、doneの3つ分作成しています。

Tauri Api

Tauri Apiはデスクトップアプリから欲しい情報を取得したりするときにフロントエンドから呼び出せるモジュールがたくさんあります。

osfsdialogclipboardなどなどあります。

今回は、ローカルファイルのファイルパスを取得したいので、dialogを使います。

dialogのopenを使うことで、よくあるファイルを選択するダイアログが起動します。

App.tsx
import { open } from "@tauri-apps/api/dialog";
/// 中略

// optionで複数選択を禁止したりディレクトリ選択にしたりできる
await open({ multiple: false })
  .then(async (files) => {
    if (files === null) return;

    // モーダルを閉じる
    setIsOpen(false);

    // 取得したファイルパスをバックエンドに投げてタスクデータを取得
    await invoke<Kanban>("initial_setting_command", {
      path: files,
    })
      .then((data) => {
        setTodo(data.todo);
        setInProgress(data.in_progress);
        setDone(data.done);
      })
      .catch(() => {});
  })
  .catch(() => {});

バックエンド実装

バックエンドの実装は以下のことをしています。

・ファイルの読み込み
・ファイルの書き込み
・フロントエンドと通信するapi

ファイルの読み込みはこんな感じです。

引数で受け取ったファイルパスを元に、serde_jsonでjsonを読み込んでいます。

function.rs
use std::io::BufReader;
use std::{fs::File, error};
use std::io::{prelude::*};

pub fn read_file(path: String) -> Result<Kanban, Box<dyn error::Error>> {
    let tasks_file = File::open(path)?;
    let reader = BufReader::new(tasks_file);
    let kanban= serde_json::from_reader(reader)?;

    Ok(kanban)
}

ファイルの書き込みはこんな感じです。

引数で受け取ったタスク情報を元に新しいTaskインスタンスを作成して、それをserde_jsonを介して文字列にしてからファイルに書き込んでいます。

function.rs
pub fn create_task(task_path: String,task: Task) -> Result<bool, Box<dyn error::Error>> {
    let task = Task {
        id: String::from(task.id),
        name: String::from(task.name),
        is_complete: task.is_complete,
    };
    let read_kanban_result = read_file(task_path.clone());

    match read_kanban_result {
        Ok(mut kanban) => {
            kanban.todo.push(task);
            let json_data = serde_json::to_string_pretty(&kanban).unwrap();
            let mut json_file = File::create(task_path).unwrap();
            writeln!(json_file, "{}", json_data);
            Ok(true)
        },
        Err(err) =>  {
            Err(err)
        }
    }
}

Tauri Command

Tauri Commandはフロントエンドとバックエンドをつなぐapiを定義します。

function.rs
#[tauri::command]
pub fn create_task_command(task: Task, task_path: State<TaskPath>) {
    let m_task_path = task_path.0.lock().unwrap();
    // エラーハンドリングは必要
    functions::create_task(m_task_path.to_string(), task);
}

定義したTauri Commandはmain関数に定義します。

main.rs
fn main() {
    tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![
        create_task_command,
        // 省略
    ])
    .setup(|app| {
        app.manage(TaskPath(Mutex::new(String::from(""))));

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

フロントエンドからはTauri Apiのinvokeを使って通信をします。

invokeはPromiseを返します。

App.tsx
import { invoke } from "@tauri-apps/api/tauri";

const handleCreateTask = async () => {
if (text === "") return;

const newTodo = {
  id: ulid(),
  name: text,
  is_complete: false,
};
await invoke("create_task_command", {
  task: newTodo,
})
  .then(() => {
    setTodo([...todo, newTodo]);
    setText("");
  })
  .catch(() => {});
};

apiの流れを図解

こんな感じです。

ビルド

ビルドはtauri-actionを参考に、GitHub Actionsで自動的にビルドされる様にしました。

mainブランチにマージした時にビルドが動くようになっています。

https://github.com/tauri-apps/tauri-action#creating-a-release-and-uploading-the-tauri-bundles

ビルドには10~15分くらいかかるので、思ったより時間がかかりました。

ローカルでもyarn tauri buildでビルドすることができます。

そんな感じでwindowsでもmacでも動く(はずの)カンバンアプリが完成しました。

githubのreleasesからダウンロードできるので、ぜひ動かしてみてください。

https://github.com/koichi-menta/alone-kanban/releases

大変だった所

Rustの勉強と理解

Rustは学習コストが高いという話は聞いていましたが、実際にドキュメントを読んで学習を始めると、確かによくわからない感じがしました。

所有権、参照、借用など、文字として読むとピンときませんでしたが、実際にアプリを作り始めてみるとその意味が分かりやすいです。

https://doc.rust-lang.org/book/

udemyの動画で手を動かしながら学習することで基礎のイメージを持つことができました。

https://www.udemy.com/course/rust-basic-course/

macでドラッグがおかしい

macのみ、1度ドラッグすると2回目のドラッグができなくなる挙動が起きてしまいました。

今回はSortableJSというライブラリを使用しているので、issueを漁っていたところ似た様な現象があったので、それを参考に対処しました。

ReactSortableコンポーネントのpropsにforceFallback: trueを追加してみたところ、うまく動きました。

https://github.com/SortableJS/Sortable/issues/1678

windowsでドラッグがおかしい

今度はwindowsのみで、そもそもドラッグができない挙動が起きてしまいました。

同じ様な現象を経験して回避した記事があったので参考にさせてもらいました。

tauri.config.jsonfileDropEnabled: falseを追加することで、一旦は回避することができました。

"windows": [
  {
    // 省略
    "fileDropEnabled": false
  }
]

https://zenn.dev/yogarasu/articles/5b063cc9c345df

アプリの改善点

今回は最低限カンバン機能を持ったプロトタイプを作成したので、これからどんどん追加実装をしていきたいです。

  • UIを整える
  • アプリを落とすとjsonを読み直す必要がある
  • カンバンのセクションが固定
  • タスクの並び替えができない
  • タスクを完了にできない
  • タスクに詳細文や画像を紐づけたい
  • タスクに色をつけたい
  • 削除したタスクをアーカイブ化したい
  • 初回のテンプレートファイルを自動で作成する

などなど、いっぱいやることはありますが、少しずつアップデートしていこうと思います。

総合的な所感

使い慣れたフロントエンドでアプリのUIが作成できるのは、クロスプラットフォームの利点だなと感じました。

また、バックエンドもクロスプラットフォーム特有の書き方は少なく、根幹部分は純粋なRustで書けそうなので、思ったよりもシンプルにデスクトップアプリ開発ができそうな印象です。

今のところバックエンドはRustしか対応していないので、今、Tauriを始める学習コストは高いかもしれませんが、今後はGo言語などでも開発ができる様になる想定らしいので期待値は高いです。

さらにモバイルアプリも作れる様になったらかなり熱いですね。

以上、今後が楽しみなクロスプラットフォーム、Tauriでカンバンアプリを作った話でした。

参考にした記事

https://zenn.dev/kumassy/books/6e518fe09a86b2

https://doc.rust-lang.org/book/

https://doc.rust-jp.rs/book-ja/title-page.html

https://www.udemy.com/course/rust-basic-course/

Discussion