Next.js × Tauriで高速なデスクトップアプリを実装する

この記事は、AEON Advent Calendar 2024の７日目です🎉

https://qiita.com/advent-calendar/2024/aeon

はじめまして！
イオンリテールという、AEONグループの総合小売業の企業で働く渡邊と申します。

総合小売業とは衣・食・住・H&BCに各テナントを合わせた広い領域を扱う小売業となります。そこで現在私は**ネットスーパーの現場オペレーション効率化**に関する部署におり、本記事ではそこで作ったアプリケーションのお話をしたいかなと思います。

## 1. 誰でも一瞬でエクセル処理できる環境が必要になった

::: note
ネットスーパーのイメージがわかない方も多くいらっしゃるかと思いますので、少し背景についてお話します
そんなのいらん！技術的な話だけで十分！という方は次の章からお読みください
:::

### 1.1. 欠品・代替処理の必要性

弊社で実施しているネットスーパーは

**売場から商品を集荷（ピッキングと呼んでいます）、店舗から出荷する**形態をとっています。ですので、webの画面と店舗の実在庫にタイムラグがあり、**お客さまが注文したタイミングでは商品が存在したものの、実際の集荷のタイミングでは商品が売れてしまった**ということが発生します。

売場で商品全体を集荷し、バックルームの作業場でお客さま毎に商品を振り分ける、という作業の際、そのような欠品・代替品の情報を作業場に引き継がなければなりません。

### 1.2. 売場ピッキング履歴のエクセルデータ処理

現在弊社では、下写真のような手持ち端末を利用して、ピッキング作業を行う方法の導入を進めています

![top-recommend.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/f38e4083-8509-af34-dd53-8860faf75235.jpeg)

ですので、欠品・代替品に関する売場での処理自体はデータとして存在しています。ですが、**現在150店舗ほどでは作業場での商品振り分け時に、過去から利用している別の振り分けシステムを利用しているため、データが引き継がれません**。

そのため、**売場ピッキングの全履歴が記載されたエクセル**を、

- 管理画面のwebからダウンロードして、
- 欠品代替情報だけを表示させて、
- 印刷して見ながら参照する

という手順が発生するのです。

ですがこのエクセル処理がなかなかの曲者で、

- 32列あるエクセルファイルから、必要な列だけを表示させる
- 明細修正の列が`"有"`の行でフィルターする
- A4で印刷されるように横幅を調整する

ことが必要となります。

この作業が**現場150店舗で1日6~10回程度発生**します。実施するのはお店のパートタイマーさんです。**道行くお姉さんに実施してもらう**ことをイメージしてもらうとわかりやすいかと思います。もちろん、「そんなの楽勝～♪おけ丸水産🐡」という方もいらっしゃるのですが、多くの方にとっては苦行です。
**結果として従来の紙でメモを取って、売場の欠品・代替情報を作業場に引き継ぐ方式から脱却できませんでした。**

> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/3d0f703f-0d6c-abc3-2cc8-9b503900d07a.png" width="75%" />
> 
> こんなの、できるわけない…

これを現場で迅速に実行できるようにするというのが、今回の企画となります

> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/74c6f355-5db4-7cd1-9760-989b4e2f329a.png" width="75%" />
>
> こんな感じのエクセルファイルを処理しないといけません
> 何が何だかわかりませんね

これを何とかしてくれ、という現場からの悲鳴が上がりましたが、開発側も簡単には動けません。ですので、これを解決するものを実装した、というのが本記事の趣旨となります。

## 2. デスクトップアプリとして実装

上記課題を解決するために、デスクトップアプリでの実装を選択しました

- 1度インストールしてしまえば基本無料で使える
- エクセル自体はPCにダウンロードするので、システム上で処理するのが早い
- アプリ立ち上げ ⇒ ファイルをドラッグ&ドロップ ⇒ 目的のエクセルファイルを生成、というシンプルなフローにしたい

という理由です。
選択した技術スタックは以下の通り

- `Next.js`
    - 言わずと知れたReactベースのフロントエンドフレームワーク
    - webアプリケーションのフロントからバックエンドも実装できます
    - 後の部分に記述しますが、バージョン更新には注意が必要です
- `Tauri`
    - **今回の肝**
    - `Rust`で処理を、`HTML`, `CSS`, `JS`のweb技術でフロントを記述して、デスクトップアプリを実装できます
    - Next.jsで記述したフロントを利用したデスクトップアプリも、これで実装できます

https://v2.tauri.app/

### 2.1. Next.jsの利点

フロント側はNext.jsを採用しました。一般的にNext.jsは大規模なフルスタックWebアプリケーションというイメージが強いかもしれませんが、以下の理由でハッカソンのように素早くプロトタイプを構築する場面でもメリットがあります。

Tauriと組み合わせる上でのメリットを`白塗りで表示`

- `Shadcn/UIの導入が容易`
- `V0との相性が良い`
- Vercelへのデプロイが迅速
- バックエンド側も一貫して実装できる

そのような理由でNext.jsに慣れていたので利用しました。
特に、V0にフロントを書かせる場合、デフォルトでShadcn/UIを使いますので、実装初期の工程をかなり削減できます。

https://v0.dev/

https://ui.shadcn.com/

もちろん、Next.jsである必然性はありませんので、よくあるVite + React、もしくは純粋なHTML, CSS, JSでも問題ありません。

### 2.2 Tauriの利点

デスクトップアプリを実装する上で、私の技術スタックからPythonによる実装、Electronによる実装、PowerAutomate Desktopによる実装も候補に挙がりました。それぞれと比較した際のメリットを記述します。

#### 2.2.1. Python（Pyinstaller）との比較

- Webのフロントを流用できるため、リッチなUIを容易に実装しやすい
- バンドルサイズが小さい

以前TkinterとPyinstallerで、私が個人的に自動化していたPythonのコードをアプリ化して他の人でも使えるようにしたことがあったのですが、その際フロントを実装するところで非常に苦労した記憶がありました。自分のイメージ通りにフロントを実装できないことは非常にストレスが溜まりますので、~~もうやりたくないと思い~~除外しました。

#### 2.2.2. Electronとの比較

- バンドルサイズが小さい
- ~~Rustが勉強できる~~

Electron自体を触ったことはなかったのですが、ドキュメント読んでいけそうだなとは思いました。ですが、Node.jsとChromiumをバンドルに加えるためビルド後のアプリケーションが大きくなるとのことです。店舗に配布することを考えるとあまり大きなファイルにしたくないと思い除外しました。
~~Rustを勉強できることをメリット取るか、デメリットと取るかはあなた次第です。~~

#### 2.2.3. PowerAutomate Desktopとの比較

- 機能制限がほとんどない

PowerAutomate Desktopというローコードの自動化アプリを利用することも考えました。ですが、無料ではエクセルの編集機能は、制限されていた記憶がありました。せっかく実装し始めたものの、制限で頓挫するのは非常に気分が悪いので除外しました。

## 3. 実装

基本的なJavaScriptの開発環境（Node.jsとか）、Rustの開発環境（CargoとかRust analyzer）は準備しておきましょう

Rust初めての方は次のページに従って準備してみましょう

https://www.rust-lang.org/ja/tools/install

Tauri自体は次のページに従って`create-tauri-app`で準備できます。ですが、Next.jsを利用する場合少しだけ注意点があります。

https://v2.tauri.app/start/create-project/

### 3.1. Next.jsの準備

次のページに記述があるように2024年12月7日現在、TauriはNext.jsのバージョン14.2.3での動作を保証しています。ですので、次のコマンドでバージョン指定をしましょう

```bash
bunx create-next-app@14.2
```

次に、Next.jsのビルドをTauriで利用できる形式に変更します。create-next-appで生成された`next.conf.mjs`に次のコードを貼り付けます

```js:next.conf.mjs
const isProd = process.env.NODE_ENV === 'production';

const internalHost = process.env.TAURI_DEV_HOST || 'localhost';

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Ensure Next.js uses SSG instead of SSR
  // https://nextjs.org/docs/pages/building-your-application/deploying/static-exports
  output: 'export',
  // Note: This feature is required to use the Next.js Image component in SSG mode.
  // See https://nextjs.org/docs/messages/export-image-api for different workarounds.
  images: {
    unoptimized: true,
  },
  // Configure assetPrefix or else the server won't properly resolve your assets.
  assetPrefix: isProd ? undefined : `http://${internalHost}:3000`,
};

export default nextConfig;
```

詳細は次のページにあるので、確認してみてください

https://v2.tauri.app/start/frontend/nextjs/

### 3.2 Tauriの準備

基本的には次のページにある`Manual Setup (Tauri CLI)`の通りに実施していただければ大丈夫です

https://v2.tauri.app/start/create-project/#manual-setup-tauri-cli

ポイントとなるのは、Next.jsのルートディレクトリに移動して

```bash
cargo tauri init
```

を実行することでしょうか。
あと、開発環境のlocalhostのポート番号が、公式ドキュメントでは5173になっていますが、Next.jsの場合は3000なので、確認してもらえればと思います

#### 3.2.1 注意点（おそらくTauri2.0以降不要）

現在はTauri2.0が出て不要なのですが、このアプリをつくった当初は1.0だったので、`.vscode/settings.json`に次の記述をしないと、rust-analyzerが`Cargo.toml`を見つけられず開発できませんでした。

```json: .vscode/settings.json
{
  "rust-analyzer.linkedProjects": [
    "<Next-app-root>/src-tauri/Cargo.toml"
  ],
}
```

`<Next-app-root>`にはNext.jsのルートディレクトリ名を書いてください

後は好きに実装するだけですが、いくつかポイントがあるので記述します

### 3.3. invokeでフロントからRustのコードを実行する

invokeという機能を利用して、WebAPIにfetchする感覚で、Rust側のコードを実行できます

#### 3.3.1. npmパッケージのインストール

```bash
bun add @tauri-apps/api
```

これでtauri-apps/apiをインストールします

https://www.npmjs.com/package/@tauri-apps/api

#### 3.3.2. フロント側の記述

フロント側は次のような感じで、Rust側で定義している`get_pass`という関数を呼びだせます。WebAPIをfetchするときのコードとほぼ変わらないことがわかります。

```tsx
'use client'

import { useEffect, useState } from 'react'
import { Card } 
import { invoke } from '@tauri-apps/api/core'

const Fetcher = () => {
  const [result, setResult] = useState<string | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [isCopied, setIsCopied] = useState(false)

  const handleFetchResult = async () => {
    setIsLoading(true)
    setResult(null)
    setIsCopied(false)
    try {
      const res = await invoke<string>('get_pass');
      setResult(res)
    } catch (error) {
      console.error('API呼び出しエラー:', error)
      setResult('エラーが発生しました')
    } finally {
      setIsLoading(false)
    }
  }

  useEffect(() => {
    handleFetchResult()
  }, [])


  return (
    <Card className="w-[300px]">
     {/* 省略 */}
    </Card>
  )
}

export default Fetcher
```

#### 3.3.3. Tauri側の記述

基本は`lib.rs`というファイルが生成されているので、ここに記述します。ポイントとなるのは、

- runの中にinvoke_handlerを記述し、関数名も記述
- 実行したい関数の前に`#[tauri::command]`を記述

次のコードは、今回のプロダクトとは別のものですが、ブラウザのログイン処理から必要なデータのwebスクレイピングを行い、フロントに返すものとなっています。先ほどのフロントのコードと合わせることで、アプリ立ち上げ時にこれを実行して、表示することができます

```rust: lib.rs
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
  tauri::Builder::default()
    .setup(|app| {
      if cfg!(debug_assertions) {
        app.handle().plugin(
          tauri_plugin_log::Builder::default()
            .level(log::LevelFilter::Info)
            .build(),
        )?;
      }
      Ok(())
    })
    .invoke_handler(tauri::generate_handler![
      get_pass
    ])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

use headless_chrome::{Browser, LaunchOptions};
use scraper::{Html, Selector};

#[tauri::command]
fn get_pass() -> Result<String, String> {
  // ブラウザを起動
  let browser = Browser::new(LaunchOptions::default()).map_err(|e| e.to_string())?;
  let tab = browser.new_tab().map_err(|e| e.to_string())?;

  // ページを開く
  tab.navigate_to("https://example.com")
    .map_err(|e| e.to_string())?
    .wait_until_navigated()
    .map_err(|e| e.to_string())?;

  // タイトルを取得して表示
  let title = tab.get_title().map_err(|e| e.to_string())?;
  println!("Title: {}", title);

  // UIDフィールドに値を入力
  tab
    .find_element("#uid").map_err(|e| e.to_string())?
    .type_into("pass").map_err(|e| e.to_string())?;

  // ボタンをクリック
  tab.
    find_element("#token > input:nth-child(8)")
    .map_err(|e| e.to_string())?
    .click()
    .map_err(|e| e.to_string())?;

  // ページのHTMLを取得
  let html: String = tab.get_content().map_err(|e| e.to_string())?;
  let document = Html::parse_document(&html);

  // セレクターを用意してデータを抽出
  let mut input_pass = String::new();
  let selectors = vec![

  ];

  for selector_str in selectors {
    let selector = Selector::parse(selector_str).map_err(|e| e.to_string())?;
    if let Some(element) = document.select(&selector).next() {
      input_pass += element.text().collect::<String>().as_str();
    }
  }

  println!("Input Pass: {}", input_pass);

  Ok(input_pass)
}
```

~~Rustのコード結構えぐいですよね~~

### 3.4. Rust側へのアクセスが必要な状態管理

ドラッグ＆ドロップ等、webの場合はフロント側で管理しますが、Tauriの場合、Rust側にlistenすることが必要になります。サンプルコードは以下の通り、

```tsx
import React, { useEffect, useState } from 'react';
import { listen } from '@tauri-apps/api/event';

interface FileDropZoneProps {
  filePath: string | null;
  errorMessage: string | null;
}

const FileDropZone: React.FC<FileDropZoneProps> = ({ filePath, errorMessage }) => {
  const [isHovered, setIsHovered] = useState(false);

  useEffect(() => {
    const unlistenDropHover = listen('tauri://file-drop-hover', () => {
      setIsHovered(true);
      console.log('File Hovered');
    });

    const unlistenDropCancelled = listen('tauri://file-drop-cancelled', () => {
      setIsHovered(false); 
      console.log('File Drop Cancelled');
    });

    const unlistenDrop = listen('tauri://file-drop', (event) => {
      const paths = event.payload as string[];
      if (paths && paths.length > 0) {
        setIsHovered(false);
        console.log('File Dropped:', paths[0]);
      }
    });

    // クリーンアップ
    return () => {
      unlistenDropHover.then((unlisten) => unlisten());
      unlistenDropCancelled.then((unlisten) => unlisten());
      unlistenDrop.then((unlisten) => unlisten());
    };
  }, []);

  return (
    <div
      className={`border-2 border-dashed px-10 py-4 text-center w-full transition-colors ${
        isHovered ? 'border-gray-500 bg-gray-100' : 'border-gray-300'
      }`}
    >
      <div className='min-h-24 flex flex-col justify-center items-center'>
        {filePath ? (
          <p>{filePath}</p>
        ) : (
          <p>ここにファイルをドラッグ＆ドロップしてください</p>
        )}
        {errorMessage && (
          <p className="text-red-500 text-center pt-2">{errorMessage}</p>
        )}
      </div>
    </div>
  );
};

export default FileDropZone;
```

ファイルをドラッグ＆ドロップして、そのパスを取得するコンポーネントですが、`useEffect`内にあるように、Tauri側にアクセスしています。

## 4. 結果

https://x.com/shuyin02/status/1841274819926700275

> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2100048/6aa3f6c0-d77d-3fbd-673f-e770d137ea2b.png" width="75%" />
>
> ドラッグ&ドロップ一発で生成

動画と画像のエクセルが若干違いますが、現場から意見をいただいて、画像のような感じに改変しています。この速度が良いですね。

## 5. 現在

**店舗のPCで使えなかったから、普通に会社のGoogle CloudのCloud RunにWebアプリケーションとしてデプロイしました。**

インストールしたその日は問題なく使えたのですが、次の日になぜかエクセルファイルへのアクセスが拒否されるようになってしまったんですよね。

なので、今後は店舗で使うものはCloud Runに、本社で使うものはTauriで実装していこうと思いました。