LoginSignup
44
26

Tauri でのオレオレ UI 状態管理とコンフィグ管理 v1

Last updated at Posted at 2024-03-02

はじめに

GUI アプリにおいては、あらゆる面におけるステート・コンフィグ管理が欲しくなる時がある。
言語設定や Dark/Light テーマ設定などの設定に加え、アプリ終了時にどのページを開いていたかなどを永続保存しておきたい事も多い。

Tauri にはそうした状態管理を行う専用のモノはないが、案内はある。
しかしながら、実際にはどういったコードに落とし込んでいればいいかを模索するため、現時点ではベースはこうしておいたほうがいいかな?みたいなのを残しておきたく思った。

image.png

環境

  • Windows 11 Pro 23H2
  • Rust 1.74.1
  • Tauri 1.5.3
  • TypeScript 5.0.0
  • Svelte 4.2.7

サンプルパッケージ

Tauri でのステート管理の基本

Tauri ではスタートアップ時に .manage(state) で追加することで、フロントエンドとバックエンド両方からステートにアクセスすることが出来るようになる。
UI の状態管理や、コンフィグとしての書き出しとしてもこの機構を利用することで、そもそもコンフィグに書き出すだけのステートなのか、単にアプリ上のステート管理なのかをスイッチしながら管理できる。

公式のドキュメントとしては以下の一部に記載がある。

基本的には、

  1. sturct State でステート管理用の構造体(箱)を用意する
  2. それにアクセスする Tauri コマンドを実装する
  3. Tauri アプリスタート時にステート用管理用構造体を .manage(State) に登録する
  4. Tauri コマンドを .invoke_handler(tauri::generate_handler![my_custom_command]) で登録していく

という流れ。

main.rs
struct MyState(String);

#[tauri::command]
fn my_custom_command(state: tauri::State<MyState>) {
  assert_eq!(state.0 == "some state value", true);
}

fn main() {
  tauri::Builder::default()
    .manage(MyState("some state value".into()))
    .invoke_handler(tauri::generate_handler![my_custom_command])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

オレオレステート管理 v1

では早速本題。

まず要件としては、

  • コンフィグ管理としてファイルに書き出す機能がある
  • 複数の状態管理がある
    • 「設定」の状態管理
    • 「終了時 UI」の状態管理

設定要素の構造を作成

設定要素を定義してく。
この際にはまず struct で構造体を作成する。

サンプルとして、以下を「設定」の要素とする。

  • 言語設定: language
  • テーマ設定: theme
config.rs
// Debug は Display 実装をしてくれ、println!() 時で stdout 部分などを実装してくれる。
// Clone は、構造体を転写して渡すときに使用する。これは後に Tauri Command 時に使用する。
#[derive(Debug, Clone)] 
struct Settings {
    language: String,
    theme: String,
}

デフォルト値の設定

Rust の標準機能では、Defaut derive アトリビュートを使用したデフォルト値の設定ができる。

main.rs
#[derive(Debug, Default, Clone)] 
pub struct Settings {
    language: String,
    theme: String,
}

fn main() {
    let settings = Settings::default();
    println!("{:?}", settings);
}

String trait は Default trait の default() として、"" (空の文字列) を返す実装をしているので、次の結果が得られる。

Settings ( settings: "", theme: "" )

しかしながら、ここでは任意のデフォルト値を設定したい。
例えば、デフォルトの言語は英語 (en) にし、テーマはダークモード (dark) にしたいとする。

この場合には Default trait の実装を行う。

config.rs
// 機能被りになるため、Default derive アトリビュートは削除する。
#[derive(Debug, Clone)] 
pub struct Settings {
    language: String,
    theme: String,
}

impl Default for Settings {
    fn default() -> Self {
        language: "en".to_string(),
        theme: "dark".to_string(),
    }
}

この状態で default() を実行すると、そのデフォルト値が設定されたまま返される。

main.rs
use config::Settings;

fn main() {
    let settings = Settings::default();
    println!("{:?}", settings);
}
Settings ( settings: "en", theme: "dark" )

コンフィグ管理としてのファイル書き出し機能実装

これを実装するために、Tuari の依存関係にも含まれる serde と serde_json を使用する。

Serialize / Deserialize の実装

config.rs
use serde::{Deserialize, Serialize};

// Deserialize, Serialize で JSON <-> 文字列 変換できるようにする。
#[derive(Debug, Deserialize, Serialize, Clone)] 
struct Settings {
    language: String,
    theme: String,
}

ファイルの書き込み / 読み込み

コンフィグには以下の機能がまずある。

  • ファイルの書き込み
  • ファイルの読み込み

さらに、これが「複数のコンフィグファイルが存在している可能性がある。」という事も重要。
ということで、コンフィグファイルが増えて似た機能を実装することになっても良いよう、まずは専用 Config trait を実装していく。

config.rs
trait Config {
    fn write_file(&self) {}
    fn read_file(&mut self) {}
}

そして、この trait の機能を実装していく。

config.rs
const SETTINGS_FILENAME: &str = "settings.json";

impl Config for Settings {
    fn write_file(&self) {
        let config_file = PathBuf::from(SETTINGS_FILENAME);
        let serialized = serde_json::to_string(self).unwrap();
        let mut file = fs::File::create(config_file).unwrap();
        file.write_all(&serialized.as_bytes()).unwrap();
    }

    fn read_file(&mut self) {
        let config_file = PathBuf::from(SETTINGS_FILENAME);
        let input = fs::read_to_string(config_file).unwrap();
        let deserialized: Self = serde_json::from_str(&input).unwrap();
        let _ = mem::replace(self, deserialized);
    }
}

std::mem::replace
この機能を使用することで、A (self) と B (deserialized) の値を直接メモリ参照先を上書きすることで書き換えることができる。

ここで問題になるのが、新しいフィールドを追加した際に、コンフィグファイルにはそのフィールドが存在しなかった場合。つまり、A にフィールドが存在するが、B には存在しない場合ということ。
フィールドのキーが A.(keys) = B.(keys) になっていないとエラーを起こす。

これを避けるためには、A 側のフィールドをあらかじめ Option 化しておく。

struct Settings {
    language: Option<String>,
    theme: Option<String>,
}

こうすることで、A に存在して B に存在しないフィールドには上書きされず、結果的に None が入ったままになる。
そして実際に値を使用する際には、Option 処理を行いつつ値を使用する。

OS 依存のコンフィグ配置先を設定する

コンフィグの設定先は、Windows では %APPDATA%%LOCALAPPDATA% が多いが、Linux や MacOS などの UNIX ベースでは、 $HOME フォルダに配置されることが多い。

この配置に対応するため、OS依存のルート取得機能を実装する。

config.rs
#[cfg(target_os = "windows")]
fn get_config_root() -> PathBuf {
    let appdata = PathBuf::from(std::env::var("APPDATA").unwrap());
    appdata.join("takanori").join("myapp")
}

#[cfg(target_os = "linux, macos")]
fn get_config_root() -> PathBuf {
    let home = PathBuf::from(std::env::var("Home").unwrap());
    home.join(".takanori").join("myapp")
}

これを元に設定ファイルの書き込み、読み込み部分を書き換える。

config.rs
impl Config for Settings {
    fn write_file(&self) {
-       let config_file = PathBuf::from(SETTINGS_FILENAME);
+       let config_file = get_config_root().join(SETTINGS_FILENAME);
+       if !config_file.parent().unwrap().exists() {
+           fs::create_dir_all(config_file.parent().unwrap()).unwrap();
+       }
        let serialized = serde_json::to_string(self).unwrap();
        let mut file = fs::File::create(&config_file).unwrap();
        file.write_all(serialized.as_bytes()).unwrap();
    }

    fn read_file(&mut self) -> Settings {
-       let config_file = PathBuf::from(SETTINGS_FILENAME);
+       let config_file = get_config_root().join(SETTINGS_FILENAME);
        let input = fs::read_to_string(&config_file).unwrap();
        let deserialized: Settings = serde_json::from_str(&input).unwrap();

        deserialized
    }
}

設定 (Settings) の機能実装

新規作成機能 (イニシャライザ) を実装する

新規作成の条件として、次のような要件を与える。

  • コンフィグファイルが見つかれば読み込んで渡す
  • コンフィグファイルが見つからなければ、デフォルト値を渡す

これを実装するために、ここでは Settings の実装を行う。

config.rs
const SETTINGS_FILENAME: &str = "settings.json";

impl Settings {
    pub fn new() -> Self {
        let config_file = get_config_root().join(SETTINGS_FILENAME);
        if !config_file.exists() {
            Self::default()
        } else {
            let mut settings = Self::default();
            settings.read_file();
            settings
        }
    }
}

Rust Idiom: new()

Rust のドキュメントではあまり明示されていませんが、Rust では ::new() 関数を使って初期化を行うケースが多々存在します。

アクセサを実装する

Settings の値にアクセスして設定し、設定項目をファイルに書き出すアクセサを実装する。
必要に応じて getter も設定する。

config.rs
impl Settings {
    pub fn set_language(&mut self, new_lang: String) {
        self.language = new_lang;
        self.write_file();
    }

    pub fn set_theme(&mut self, new_theme: String) {
        self.theme = new_theme;
        self.write_file();
    }
}

アプリステートを作成する

Tauri に、実際に管理させるアプリ状態管理のベース構造を作成する。

実際のところ、直接 Settings を Tauri アプリに渡して管理させるという手もあるが、アプリステートとして管理層をラップしておくことで、後から読み込み頻度が高い UI ステートから設定ステートに変更したり、追加したりなどをハンドリングしやすくなるので、あえてアプリステート「層」を用いるという手段を扱う。

この際、Settings は「ファイルを扱う」という特性上、ファイル IO にも関わるのでこの時点で Mutex として保持するように設定する。
こうすることで、非同期時や並行実行時にかち合う事がなくなる。

config.rs
#[derive(Debug)]
pub struct AppState {
    settings: Mutex<Settings>,
}

impl AppState {
    pub fn new() -> Self {
        Self {
            settings: Mutex::from(Settings::new()),
        }
    }
}

アプリステートを Tauri アプリの管理下に入れる

作成した AppState::new() で、アプリの状態管理を初期化する。
この時点で、これまでの実装の通りコンフィグからも読み込まれた状態になっている。
(スッキリ!)

main.rs
mod config;

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

    tauri::Builder::default()
+       .manage(app_state)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Tauri コマンドを実装する

フロントエンド側から関数を実行できるようにするため、Tauri コマンド化して、Tauri アプリに登録する。
ここでは、あえて pub mod commands {} スコープに閉じ込めており、呼び出し時に Tauri Command を呼び出しやすくしている

また、スコープが外れていると、 AppState なども見えなくなるため、use super::*; を使用して、親スコープの要素も使用できるようにしている。

Setter Tauri コマンド

config.rs
pub mod commands {
    use super::*;

    #[tauri::command]
    pub async fn set_language(
        state: tauri::State<'_, AppState>,
        new_language: String,
    ) -> Result<(), String> {
        let mut settings = state.settings.lock().unwrap();
        settings.set_language(new_language);
        Ok(())
    }

    #[tauri::command]
    pub async fn set_theme(
        state: tauri::State<'_, AppState>,
        new_theme: String,
    ) -> Result<(), String> {
        let mut settings = state.settings.lock().unwrap();
        settings.set_theme(new_theme);
        Ok(())
    }
}

Getter Tauri コマンド

Getter 側では返す値を設定する必要があるので、Result の成功型に返す構造体を渡す。
例えば、以下では Settings の内容を取得したいので、Settings を返すようにし、実際に Ok(settings) で返している。

この際、Mutex は .lock().unwrap() しても MutexGuard がかかったもので、基本的にそのスコープを抜ける形になる渡し方はできない。なので、.clone() して渡す用にする。
ここが当初の時点で Settings に対して Clone tarit の機能を付与していた理由でもある。

config.rs
pub mod commands {
    use super::*;
    #[tauri::command]
    pub async fn get_settings(
        state: tauri::State<'_, AppState>
    )-> Result<Settings, String> {
        let settings = state.settings.lock().unwrap().clone();
        Ok(settings)
    }
}

Tauri コマンドを登録する

定義した Tauri Command 関数を、ビルド時の .invoke_handler()generate_handler![] マクロを使用して登録する。
複数コマンドもリストとして登録できるので、作成毎に追加していく。

main.rs
use config;

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

    tauri::Builder::default()
        .manage(app_state)
+       .invoke_handler(generate_handler![
+           config::commands::set_language,
+           config::commands::set_theme,
+       ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

config.rs 全体はこんな感じ

フロントエンドからの呼び出し

ここでは、Svelte を使用した場合の呼び出し方を記述する。

フロントエンド側では、@tauri-apps/apiinvoke() を使用して呼び出す。

npm install @tauri-apps/api

特にここで注意したいのが、 invoke('command', { argKey: argValue}) の argKey 部分。
Rust 側の実装だと new_language となっているものは、TypeScript 側では newLanguage という形で渡されることになる。

Rust 側でそれに合わせて実装してもいいが、Rust 的には #[allow(non_snake_case)] を付与しなければいけなかったり、あまり美しくないので、このままにしている。
(個人的には変数や関数名はスネークケースの方が見やすくて好き。)

<script lang="ts">
	import { invoke } from '@tauri-apps/api';

	type Settings = {
		language: string;
		theme: string;
	};

	let languageSelected = 'en';
	let themeSelected = 'dark';

	invoke('get_settings').then((res: Settings) => {
		languageSelected = res.language;
		themeSelected = res.theme;
	});
 
 	async function onLaunguageChanged() {
		await invoke('set_language', { newLanguage: languageSelected });
	}

	async function onThemeChanged() {
		await invoke('set_theme', { newTheme: themeSelected });
	}
</script>

サンプルコード

output-palette.gif

+page.svelte
<script lang="ts">
	import { invoke } from '@tauri-apps/api';
	import { Select, Label } from 'flowbite-svelte';

	type Settings = {
		language: string;
		theme: string;
	};

	let languageSelected = 'en';
	let themeSelected = 'dark';

	const languageSets = [
		{ value: 'en', name: 'Engilish' },
		{ value: 'ja', name: '日本語' }
	];

	const themeSets = [
		{ value: 'dark', name: 'Dark' },
		{ value: 'light', name: 'Light' },
		{ value: 'class', name: 'OS Setting' }
	];

	invoke('get_settings').then((res: Settings) => {
		languageSelected = res.language;
		themeSelected = res.theme;
	});

	async function onLaunguageChanged() {
		await invoke('set_language', { newLanguage: languageSelected });
	}

	async function onThemeChanged() {
		await invoke('set_theme', { newTheme: themeSelected });
	}
</script>

<div class="flex flex-col">
	<ul class="m-3 divide-y-2">
		<li class="flex justify-between py-3">
			<div class="flex flex-col">
				<Label class="font-semibold">Language</Label>
				<Label class="text-gray-400 ">Language settings</Label>
			</div>
			<Select
				size="lg"
				items={languageSets}
				bind:value={languageSelected}
				on:change={onLaunguageChanged}
				class="rounded-lg border-2 border-rose-500 p-3"
			/>
		</li>
		<li class="flex justify-between py-3">
			<div class="flex flex-col">
				<Label class="font-semibold">Theme</Label>
				<Label class="text-gray-400 ">Theme settings</Label>
			</div>
			<Select
				size="lg"
				items={themeSets}
				bind:value={themeSelected}
				on:change={onThemeChanged}
				class="rounded-lg border-2 border-rose-500 p-3"
			/>
		</li>
	</ul>
</div>

おまけ: コンフィグを yaml で保存する

手動でコンフィグを操作したい場合、json だと読みづらくなることが多々ある。
そういった場合に yaml を好んで使うケースもある。

yaml を使用したい場合は、serde_yaml をパッケージに追加して、参照を serde_yaml に変えるだけ!
シンプルである。

cargo add serde_yaml
Cargo.toml
[dependencies]
serde_yaml = "0.9.32"

実行部分とファイル拡張子を書き換える。

config.rs
-const SETTINGS_FILENAME: &str = "settings.json";
+const SETTINGS_FILENAME: &str = "settings.yaml";

impl Config for Settings {
    let config_file = get_config_root().join(SETTINGS_FILENAME);
    fn write_file(&self) {
        let config_file = get_config_root().join(SETTINGS_FILENAME);
        if !config_file.parent().unwrap().exists() {
            fs::create_dir_all(config_file.parent().unwrap()).unwrap();
        }
        let serialized = serde_json::to_string(self).unwrap();
        let mut file = fs::File::create(config_file).unwrap();
        file.write_all(&serialized.as_bytes()).unwrap();
    }

    fn read_file(&mut self) {
        let config_file = get_config_root().join(SETTINGS_FILENAME);
        let input = fs::read_to_string(config_file).unwrap();
-       let deserialized: Self = serde_json::from_str(&input).unwrap();
+       let deserialized: Self = serde_yaml::from_str(&input).unwrap();
        let _ = mem::replace(self, deserialized);
    }
}

まとめ

これで、ある程度設定項目の管理がしやすい状態を作れたと思う。
実際には、コンフィグの内容がバージョンごとに変化したりするため、それらに対応していくコードなどが追加されていくだろう。
そうした場合には v2 として発展させていこうと思う。

今回は一旦ここまで。

44
26
6

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
44
26