Tauriでデスクトップアプリを作ってみる

# はじめに
Tauriが1.0版リリースされた、RustのGUI層として学習する価値があると思うので、学習ノートも残す。

# 作るアプリ

# 使用する技術
[Tauri](https://tauri.app/release-notes), [Vite](https://ja.vitejs.dev/), [Vuejs3](https://v3.ja.vuejs.org/), [Tailwind Css](https://tailwindcss.com/)
[Typescript](https://www.typescriptlang.org/), [Rust](https://www.rust-lang.org/ja/), [WebAssembly](https://developer.mozilla.org/ja/docs/WebAssembly)
こんくらいかな、サーバー想定しないから、nuxtjsなどは使わない

# まずは環境準備しよう
Rustとツールチェーンをインストール
```
// Mac
xcode-select --install
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
```

* LinuxとWindowsはリンク先を参照
https://tauri.app/v1/guides/getting-started/prerequisites/

以降の説明も、Mac環境を想定する。

# 初めてのTauriアプリ

```
# yarnを使う場合
yarn create tauri-app

# npmを使う場合
npm create tauri-app

# npxを使う場合
npx create-tauri-app
```

本記事では、初期化パラメータはviteとvue-tsを選択する。
詳細は画像を参照
![スクリーンショット 2022-08-01 15.27.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27429/566262f3-3397-d64c-31a0-a2c6b5a6ecb3.png)

初期化されたプロジェクトのソース構造
```
tauri-app
├─ node_modules
├─ public
├─ src                    # vueソース
├─ src-tauri 　　　　　　　　# Tauriソース
│    ├─ icons             # アプリケーションのアイコン
│    ├─ src               # Rustソース。タスクトレーをはじめ、アプリの各種動作を定義
│    ├─ target            # コンパイル成果物
│    ├─ build.rs          # Tauri ビルドソース
│    ├─ Cargo.lock        # Rustの依存管理Cargoのロックファイル
│    ├─ Cargo.toml        # Rustの依存管理Cargoの設定ファイル
│    └─ tauri.conf.json   # Tauriアプリケーションの設定ファイル。画面サイズや要求権限など
├─ index.html             # アプリケーションのメイン画面
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
├─ yarn.lock
└─ ...
```

# 既存のプロジェクトにTauriを追加するには

```
# yarnを使う場合
yarn add -D @tauri-apps/cli
yarn tauri init

# npmを使う場合
npm install --save-dev @tauri-apps/cli
npm tauri init
```

# 開発モードのアプリを起動
```
# viteのみ起動、従来のJS/TSプロジェクトと類似し、devサーバーが立ち上げ、ブラウザで動作確認
yarn dev

# vite + tauriを起動、初回はライブラリをフルビルドので時間がかかるが、次回以降は差分ビルドとなるため速くなる。
# また、スタンドアロンのアプリケーションが立ち上げ、動作確認ができる。
yarn tauri dev
```

# WebAssemblyを組み込み
Tauriには、tauri::commandを使ってJS/TS側からRust側の処理を呼び出す仕組みがある。
それとは別に、WebAssemblyを組み込み方法もある。
WebAssemblyの組み込みはTauriがなくとも、一般なJS/TSプロジェクトでも組み込めるが手順は少し違う。

Rust側
[rsw-rs](https://github.com/rwasm/rsw-rs)をインストール
```
cargo install rsw
```

JS/TS側
[vite-plugin-rsw](https://github.com/rwasm/vite-plugin-rsw)をインストール
```
yarn add -D vite-plugin-rsw
```
vite.config.jsを変更
```
import { defineConfig } from 'vite'
import ViteRsw from 'vite-plugin-rsw'; // 追加

export default defineConfig({
  plugins: [
    ViteRsw(), // 追加
  ],
})
```

rsw用のコマンドも追加
```
{
  "scripts": {
    "dev": "vite",
    "build": "rsw build && tsc && vite build",
    "preview": "vite preview",
    "tauri": "tauri",
    "rsw": "rsw" // 追加
  }
}
```

rswの設定ファイルを初期化する
```
yarn rsw init
```
すると、rsw.tomlファイルが生成されるはず。

Webassemblyのプロジェクトを生成
```
yarn rsw new rsw-hello
```
rsw-helloフォルダが作成されたら、先ほど生成されたrsw.tomlを更新する。

```
# link type: npm | yarn | pnpm, default is `npm`
cli = "npm"

[[crates]] // ここからをrsw.tomlの最後に追加
name = "rsw-hello"
link = true
```
以上で、WebAssemblyを組み込み設定が完了する。

# 実際にアプリを作ってみる
上記で生成されたテンプレートに、Google Meetsのような[LiveChatアプリ](https://livue.pages.dev/)を作ってみる。
[サンプルのソースコード](https://github.com/gpgkd906/livue)

必要なライブラリを追加
```
yarn add peerjs
```

画面ファイルを追加

会議作成・会議参加画面
```vuejs
// src/views/Top.vue
<script setup lang="ts">
import { createMeeting, joinMeeting, state } from '../store'
</script>

<template>
<div class="meet">
    <button @click="createMeeting">
        Start a Meeting
    </button>
    <div class="note">
        The Host who started a meeting can't leave (including reload the page), or the meeting ID will be invalidated
    </div>
    <p class="dividebar"></p>
    <p></p>
    <input type="text" v-model="state.meetingId">
    <p></p>
    <button @click="joinMeeting">
        Join a Meeting
    </button>

</div>
</template>

<style scoped>
    .meet {
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 100%;
    }
    input {
        width: 600px;
        height: 40px;
        border-radius: 10px;
    }
    button {
        width: 200px;
        height: 50px;
        font-size: 20px;
        border: solid 1px black;
    }
    .dividebar {
        border: solid 0.2rem #5e104a;
        width: 700px;
        border-radius: 50px;
    }
</style>

```

会議画面
```vuejs
// src/views/Meet.vue
<script setup lang="ts">
import { state, localStream, remoteStream } from '../store'
</script>

<template>
  Meeting ID: <strong>{{ state.meetingId }}</strong>
  <p></p>
  <div class="camera-box">
    <video :srcObject="localStream" autoplay muted></video>
    <video v-show="remoteStream" :srcObject="remoteStream" autoplay></video>
  </div>
</template>

<style scoped>
.camera-box {
  display: flex;
  flex-direction: row;
  align-items: center;
}
video {
  width: 100%;
  min-width: 30vw;
  border: 1px solid #000;
}
</style>
```

App画面
```vuejs
// src/App.vue
<script setup lang="ts">
  import Top from './views/Top.vue'
  import Meet from './views/Meet.vue'
  import { meetingId } from './store'
</script>
<template>
  <!-- <peer></peer> -->
  <div v-show="!meetingId">
    <top></top>
  </div>
  <div v-show="meetingId">
    <meet></meet>
  </div>
</template>

<style scoped>
</style>
```

モデルファイルを追加、Peer2Peerの接続管理

```typescript
// src/store.ts
import { reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { Peer } from "peerjs";

export const state = reactive({
    meetingId: ''
})

export const uid = crypto.randomUUID()
export const meetingId = ref('')

/// <reference types="webrtc" />
const navigator = window.navigator;
const getUserMedia = navigator.getUserMedia ?? navigator.webkitGetUserMedia ?? navigator.mozGetUserMedia;

export const localStream: Ref<MediaStream|undefined> = ref(undefined)
export const remoteStream: Ref<MediaStream|undefined> = ref(undefined)

let peer = new Peer(uid)

const connectMeeting = (meetingId: string) => {
    const call = peer.call(meetingId, localStream.value!)
    call.on("stream", function(stream: MediaStream) {
        remoteStream.value = stream
    })

    const conn = peer.connect(meetingId);
    conn.on('open', function(){
        conn.send({ uid: uid });
    });
}

peer.on('call', function(call) {
    call.answer(localStream.value)
    call.on('stream', function(stream) {
        remoteStream.value = stream
    });
});

peer.on('connection', function(conn) {
    conn.on('data', function(data){
        console.log("ondata", data);
    });
});


export const createMeeting = () => {
    state.meetingId = uid
    joinMeeting()
}

export const joinMeeting = async () => {
    if (!state.meetingId) {
        return;
    }
    if (meetingId.value !== state.meetingId) {
        meetingId.value = state.meetingId
    }
    localStream.value = await initStream()
    if (uid !== state.meetingId) {
        connectMeeting(state.meetingId)
    }
}

const initStream = async (): Promise<MediaStream> => {
    return new Promise((resolve, reject) => {
        getUserMedia({ video: true, audio: true }, resolve, reject)
    })
}
```

最後にビルドする
```
yarn tauri build
```

ビルドが終わると、実行ファイルが作成され、そのまま実行することができる。

![スクリーンショット 2022-08-18 13.23.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27429/4fa0bdd9-250e-40f4-8ae8-12cd408c47bc.png)