Tauri アプリの CLI 機能実装

# はじめに

Tauri は GUI アプリを作成するというイメージは当たり前にあるが、実は CLI 機能の実装も可能で、その機能を [clap](https://docs.rs/clap/latest/clap/) クレートを通して有している。

https://tauri.app/v1/guides/features/cli


この機能の利用用途としては、例えばダークテーマモードで起動したり、デバッグモードで起動したりといった事が考えられる。

```powershell
myapp.exe --debug --theme dark
```

また、CLI コマンドモードとしての利用も考えられるだろう。

```powershell
myapp.exe run dev --verbose trace
```

これを実際にどう実装していけばいいかを試したので、それのメモを残す。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/7627062e-17fa-cd10-fd3c-841c400ddfd5.png)

### 環境

* Windows 11 Pro 23H2
* Tauri 1.5.3

### 実装サンプル

https://github.com/takavfx/learn_tauri_examples/tree/2024.3.23_1/cli-impl

# Tauri の CLI 機能実装

CLI 機能として実装する場合、Tauri Config での実装方法が案内されている。

Tauri Config で定義するメリットとしては、後続のアプリ開発参入者が CLI 機能実装の状態を把握しやすいのと、プログラム側を複雑化させずに済むこと。

## 定義方法

Tarui Config での定義は以下のフォーマットで行う。
この `args` リストに実際の引数を定義し、 `subcommands` でサブコマンドを定義する。

```json:tauri.config.json
{
  "tauri": {
    "cli": {
      "description": "",
      "longDescription": "",
      "beforeHelp": "",
      "afterHelp": "",
      "args": [],
      "subcommands": {
        "subcommand-name": {
        }
      }
    }
  }
}
```

### args

```json
{
  "args": [
    {
      "name": "source",
      "index": 1,
      "takesValue": true
    },
    {
      "name": "destination",
      "index": 2,
      "takesValue": true
    }
  ]
}
```

### 主な args アトリビュート

| アトリビュート名 | 説明 |
|:-:|:-|
|name|引数名。 `--[name]` として使用される。<br />Ex: `--debug`|
|short|引数のショート名 `-[short_name]` として使用される。<br />Ex: `-d`|
|index|引数のインデックス。ヘルプ時の表示順等に影響を及ぼす。|
|takesValue|値を取るかどうかの設定。`--[option] [value]` として引数が値を取るかどうかを設定する。<br />これを設定しない場合は `bool` タイプのフラッグとして扱われる。|
|description|引数の説明|
|longDescription|引数の長い説明|
|multiple|複数の値の受け取り方に対応。<br /> **`takesValue` の設定は必須。**<br /> Ex: `--add 4 10`, `-a 4 -a 10`, `--add=4,10`|
|possibleValues|受け取る値の定義を行う。<br />定義から外れた値を受け取るとエラーを返す。<br /> Ex: `["JPY", "USD", "EUR"]`|


## 値の受け取り

引数の値の受け取りは、バックエンドとフロントエンドの両方で提供されている。

### バックエンド

渡された引数が定義にマッチしているかは `tauri::App` の `get_cli_matches()` で取得する。

```diff_rust:main.rs
fn main() {
    let app_state = config::AppState::new();

    tauri::Builder::default()
        .setup(|app: &mut tauri::App| {
+            debug!("{:?}", app.get_cli_matches());
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

この `get_cli_matches()` は、`Result< Matches { args: HashMap<String, ArgData>, subcommand: Option<Box<SubcommandMatches>> } >` を返すようになっている。また、この Matches は Serialize も設定されているので次のような取得も可能。

```diff_rust
fn main() {
    let app_state = config::AppState::new();

    tauri::Builder::default()
        .setup(|app: &mut tauri::App| {
            debug!("{:?}", app.get_cli_matches());
+           match app.get_cli_matches() {
+               Ok(matches) => {
+                   debug!("{:?}", matches);
+                   // ここからマッチした値を取得してきて処理を行う。
+                   // 例えば以下のように引数 debug を取得している。
+                   // 帰ってくる値は Option<&ArgData> で渡される。
+                   if let Some(is_debug_mode) = matches.args.get("debug").clone() {
+                       debug!("{:?}", is_debug_mode);
+                   }
+               }
+               Err(e) => error!("{:?}", e),
+           }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

更に、実際に値を取り出す場合は serde の機能に則って以下の様に正しい型で取得する。
`.expect(msg)` しているのは `as_[T]()` で帰ってくるのが `Option<T>` 型であるため。

```rust
if let Some(x) = matches.args.get("key").clone() {
    // Value::Bool の場合
    x.value.args.as_bool().expect("The value is not bool type.")
    
    // Value::Array の場合
    x.value.args.as_array().expect("The value is not array type.")
    
    // Value::String
    x.value.args.as_str().expect("The value is not str type.")
    
    // Value::Null
    x.value.args.as_str().expect("The value is not null type.")
}
```


### フロントエンド

#### 依存モジュールのインストール

```powershell
npm i -D @tauri-apps/api/cli
```

#### 使い方

```svelte:+layout.svelte
<script lang="ts">
    import { getMatches } from '@tauri-apps/api/cli'
    
    $: getMatches().then((matches) => {
      //  { args, subcommand } としてわたってくるので、これに対して処理を行う。
    })
<script>
```

## サブコマンドの実装

サブコマンドは `"subcommands": {}` として定義できる。
この `subcommands` の中身は `cli` 部分を継承している。なので、複数階層を持たせることも可能。

```json
{
  "tauri": {
    "cli": {
      "subcommands": {
        "run": {
          "args": [
            {"name": "start", "takesValue": false},
            {"name": "dev", "takesValue": false}
          ]
        },
        "search": {
          "subcommands": {
            "google": {"args": [{"name": "word", "short": "w"}]},
            "bing": {"args": [{"name": "word", "short": "w"}]},
            "yahoo": {"args": [{"name": "word", "short": "w"}]}
          }
        }
      }
    }
  }
}
```

以下の様な呼び出しが出来たりする。

```powershell
myapp.exe run dev
myapp.exe run start

myapp.exe search google --word orange
myapp.exe search yahoo -w apple
```

## Windows での stdout へのパイプ

Windows 環境では、コンソール上で exe を実行しても即座に別プロセスでアプリが立ち上がるようになる。
そうすると困るのが、コンソール上に結果の出力をしてくれなくなるので CLI ツールとしての機能を持たせる目的が果たせないこと。

これを解決するには少しトリッキーなことをする必要がある。
それは、Tauri アプリを作成した際に生成される次の一文を削除すること。
コメントには「削除するな！」と書いてあるが、**実際にこれが機能することによって起動時に別コンソールを立ち上げて、メインで利用しているところからはすぐに切り離すような挙動をふるまっている**。

```diff_rust:main.rs
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
- #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
```

### 引数があるかないかによって挙動を切り替える

GUI と CLI モードを両方持たせたい場合、この挙動を切り替えたい場合もある。
なので、「引数を持たせたとき」のみ CLI としてコンソールにパイプするという挙動に切り替えるアイデアを用いる。

これを達成するには、 [`windows_sys::Win32::System::Console::AllocConsole`](https://docs.rs/windows-sys/latest/windows_sys/Win32/System/Console/fn.AllocConsole.html) を用いる。
これは、 `Win32_System_Console` feature として提供されているので、次のように追加する。

```powershell
cargo add windows-sys --features Win32_System_Console
```

```toml:Cargo.toml
[dependencies]
windows-sys = { version = "0.52.0", features = ["Win32_System_Console"] }
```

```rust
use windows_sys::Win32::System::Console::AllocConsole;

fn main() {
    unsafe {
        AllocConsole();
    }
}
```


## ヘルプとバージョンの表示

デフォルトで `--help` と `--version` が引数として持つようになっている。
なので、CLI の設定を行っている時点で次の実装でヘルプ表示を行えるようになる。

```json:tauri.config.json
{
  "tauri": {
    "cli": {
      "description": "Examples for cli implimentation in Tauri",
      "beforeHelp": "Help doc for myapp!!",
      "afterHelp": "Takanori Kishikawa, All rights reserved."
    }
  }
}
```

ヘルプ情報は `Cargo.toml` の `authors=[]` の値も使用されるので、適宜編集しておく。

```toml:Cargo.toml
[package]
name = "app"
version = "0.1.0" # ここのバージョンは無視してよい
description = "A Tauri App"
authors = ["Takanori Kishikawa"] # ここの情報は CLI の help 情報に表示されるので注意
license = ""
repository = ""
default-run = "app"
edition = "2021"
rust-version = "1.60"
```

こちらが実行部分の実装。

```diff_rust:main.rs
fn main() {
    let app_state = config::AppState::new();

    tauri::Builder::default()
        .setup(|app| {
            debug!("{:?}", app.get_cli_matches());
            match app.get_cli_matches() {
                Ok(matches) => 
                
+                   // --help の表示
+                   if let Some(x) = matches.args.get("help").clone() {
+                       println!("{}", x.value.as_str().unwrap());
+                   }
                   
+                   // --version の表示
+                   if let Some(_) = matches.args.get("version").clone() {
+                       println!(
+                           "{}, Version: {}",
+                           app.config()
+                               .as_ref()
+                               .package
+                               .product_name
+                               .clone()
+                               .expect("To get product name is failed."),
+                           app.config()
+                               .as_ref()
+                               .package
+                               .version
+                               .clone()
+                               .expect("To get version is failed.")
+                       );
+                   }
                }
                Err(e) => println!("{}", e.to_string()),
            }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

実行すると次の様に結果が得られる。

```powershell
myapp.exe --help
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/7627062e-17fa-cd10-fd3c-841c400ddfd5.png)

```powershell
myapp.exe --version
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/08a3e870-47e9-1d3a-4fc0-3473cfd678f0.png)

:::note info
**`--version` 引数について**
特に何かを指定していなくても `--version` は引数として登録されている。
しかしながら、実際に使用してみると ArgData は次のように Null の情報のみが返ってくる状態になっている。

```
Matches { args: {"version": ArgData { value: Null, occurrences: 0 }}, subcommand: None }
```

恐らく Tauri の内部の書き込みが Clap を特殊ラップしているからだとは思うが、そのままだと思った挙動にならない。
なので、現状は上記の様に自分で `--version` について独自実装する必要がある様子。
:::


# 実装サンプル

## `--dark` でテーマを切り替える

以下の様に `--dark` を引数に持たせ、どちらかで切り替えるという事を考えてみる。
デフォルト値は `light` とする。

### コマンド

```powershell
myapp.exe --dark
```

### コンフィグ

```json:tauri.config.json
{
  "tauri": {
    "cli": {
      "description": "This is MyApp CLI functions",
      "args": [
        {
            "name": "dark",
            "takesValue": false,
            "multiple": false,
            "description": "Set theme to dark mode as launched."
        }
      ]
    }
  }
}
```

### バックエンド

```rust:main.rs
mod config;

// 省略

fn main() {
    let app_state = config::AppState::new();

    tauri::Builder::default()
        // 省略
        .manage(app_state)
        .setup(|app| {
            match app.get_cli_matches() {
                Ok(matches) => {
                    debug!("{:?}", matches);

                    // 省略

                    // --dark 時の処理
                    if let Some(x) = matches.args.get("dark").clone() {
                        let app_state: State<'_, AppState> = app.state();
                        debug!("{:?}", app_state.settings.lock().unwrap());
                        let mut settings = app_state.settings.lock().unwrap();
                        settings.set_dark_mode(x.value.as_bool().unwrap());
                        debug!("{:?}", settings);
                    }
                }
                Err(e) => println!("{}", e.to_string()),
            }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

### フロントエンド

```svelte:+page.svelte
<script lang="ts">
	import { Moon, Sun } from 'lucide-svelte';
	import { invoke } from '@tauri-apps/api';

	type Settings = {
		dark_mode: boolean;
	};

	let darkMode = false;
	$: invoke('get_settings').then((res) => {
		let settings = res as Settings;
		darkMode = settings.dark_mode;
		switchDarkMode(darkMode);
	});

	async function switchDarkMode(isDarkMode: boolean) {
		await invoke('set_dark_mode', { switch: isDarkMode });

		isDarkMode
			? document.documentElement.classList.add('dark')
			: document.documentElement.classList.remove('dark');
	}

	function toggleDarkMode() {
		darkMode = !darkMode;
		switchDarkMode(darkMode);
	}
</script>

<div class="h-screen bg-zinc-100 p-3 dark:bg-zinc-900">
	<div class="rounded-lg bg-white px-6 py-8 shadow-xl ring-1 ring-slate-900/5 dark:bg-slate-800">
		<div>
			<button on:click={toggleDarkMode}>
				<span
					class="inline-flex items-center justify-center rounded-md bg-indigo-500 p-2 text-white shadow-lg"
				>
					{#if darkMode}
						<Moon />
					{:else}
						<Sun />
					{/if}
				</span>
			</button>
		</div>
		<h3 class="mt-5 text-base font-medium tracking-tight text-slate-900 dark:text-white">
			Writes Upside-Down
		</h3>
		<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
			The Zero Gravity Pen can be used to write in any orientation, including upside-down. It even
			works in outer space.
		</p>
	</div>
</div>
```

### 結果

`--dark` 引数を付けることでダークモードに切り替えた状態での起動を出来ることが分かる。

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51857/d6dece62-4795-be88-684d-32db3ea047ab.gif)


:::note warn
ここで不正な引数を渡すと次の様にエラーを起こす。

 ```myapp.log
 [2024-03-22][07:20:15][DEBUG][app] Err(FailedToExecuteApi(ParseCliArguments("error: \"da\" isn't a valid value for '--theme <theme>...'\n\t[possible values: light, dark]\n\n\tDid you mean \"dark\"?\n\nFor more information try --help\n")))
 ```


 これを取り出して String 化することで次の様なエラーメッセージをコンソールに出力することが出来る。

 ```rust
 Err(e) => println!("{}", e.to_string())
 ```

 ```
 failed to execute API: failed to parse CLI arguments: error: Found argument '--dar' which wasn't expected, or isn't valid in this context

        Did you mean '--dark'?

        If you tried to supply `--dar` as a value rather than a flag, use `-- --dar`

USAGE:
    myapp.exe --dark

For more information try --help
 ```

 ここら辺のエラーメッセージの優秀さは Clap を使用しているが故の能力。
 最小限の実装でこの読みやすさはありがたい。
 
:::

:::note info
**アプリの状態管理はバックエンドで中央集権管理する**
ダークモードの場合などは実際のところフロントエンド側で引数を受け取るだけでも完結できる。しかしながら、GUI アプリにおける状態管理は非常に複雑化しやすい性質を持っているため、アプリの状態管理としてはアプリ用のステートを用いて管理する。そうすることで、状態管理やそれを設定ファイルとして書き出す際など、アトリビュートの増大や利用目的のスケーリングに対応し易くなる。
:::

 # まとめ

コンフィグの設定から、少々書くだけでも他のライブラリ群と同じように引数を用いた実装が出来ることが分かった。

しかしながら、Clap を使用しているというドキュメントの書き込みがあるものの、結構情報が足りてないようにも思えっる Tauri の機能部分の一つだと思う。

ただ、実際に実装していく上では結構重宝する機能だとは思うのでできればもう少し深堀しておきたい。

また機能見つけたら追記予定～～ (｀･ω･´)ゞ