Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
🃏

Rust/Tauriに入門したので画像変換デスクトップアプリを開発してみた

2025/01/23に公開

こんにちは!某製造業で新米DXエンジニアをしているものです。

先月から Rust の学習を始めました。

学習している理由としては

  • 今までインタプリタ言語を使ってきたので、コンパイル言語に挑戦したい
  • 爆速で動くコードを書きたい
  • ネイティブアプリの開発をしてみたい
  • WebAssembly を使えるようになって、Web アプリ開発の幅を広げたい

といったところです。

そんな中、Rust でデスクトップアプリを開発するために Tauri を使ってみました。

正月の暇をつぶすために軽い気持ちで始めましたが、色々苦しんだので備忘録を残しておきます。

今回は長くなりそうなので、目次をご活用ください m(._.)m

アプリの概要

今回作ったアプリは、ざっくり言うと

主要形式の画像を次世代拡張子である AVIF,WEBP に圧縮、変換するデスクトップアプリです。⚡️

制作期間は5~7日くらいです。

GitHub のリポジトリはこちらです。

https://github.com/harumiWeb/tavif

アプリ名はTavifです。

Rust のネイティブアプリ開発フレームワークである Tauri を使って開発した avif 変換アプリなので、tavif という名前にしました。(適当)

以下はアプリ使用画面イメージ

(※Zennのファイルサイズの都合上、画像を圧縮しているので実際の色味とは少し異なります。)

シンプルな UI なので、特に説明は不要ですが、

画像ファイルを選択して、変換形式を AVIF か WEBP を選び、品質を 0~100 の範囲で指定して実行すると、圧縮、変換された画像が保存されます。

現在、Windows のみに対応しており、GitHub Actions でビルドしているので、
GitHub のリリースページからダウンロードできます。

https://github.com/harumiWeb/tavif/releases/tag/tavif-v0.3.0

自己署名証明書などは取得していないので、ダウンロードした際は自己責任でお願いします。

SmartScreen の警告が出るかもしれませんが、表示してインストールしていただけれお使いいただけます。

ネットワークを介さないローカルファーストなアプリなので、安心してお使いいただけます。

作ろうと思ったきっかけ

一番は記事冒頭にある通り、Rust を学びたかったというのがありますが、

せっかくアプリを開発するなら、誰か一人にでも使ってもらいたいということで

自分自身が AVIF 形式の画像を作成する際に、世の中のアプリで満足のいくアプリがなかったので

それらを解決してみたかった というのがあります。

AVIF 変換ツールで調べると色々ありますが、ネットでよくオススメされている「Crushee」というアプリを、かつては私も使っていました。

https://crushee.app/

しかし、次世代の画像フォーマットでありながら Webp よりも圧縮率が低いアプリが多く、

Avif に変換するメリットがあまりありませんでした。

そこでツールを自作してみた、というのが今回のアプリ開発のきっかけです。

開発環境

開発言語としては、RustTypeScriptを使用しました。

フレームワークは、Rust のネイティブアプリ開発フレームワークであるTauriを使用し、

フロントエンドの実装にはNext.jsを使用しました。

以下、大まかな開発環境の構成です。

  • ビルドツール:  Tauri
  • スタイリング:  TailwindCSS
  • 状態管理ライブラリ:  Jotai
  • UI ライブラリ:  Ant Design
  • フロントエンドテスト:  Vitest
  • バックエンドテスト:  Rust

Tauri とは

Tauri は最近非常にアチアチなネイティブアプリ開発フレームワークです 🔥

Electron と同じようにフロントを HTML,CSS,JavaScript のような Web 技術で作成し、

バックエンドは爆速処理が可能な Rust で作成することができます。

Tauri は開発も非常に活発で、最近メジャーバージョンの更新があり、

Windows,macOS,Linux に対応のほか、Android,iOS にも対応と凄まじい進捗を見せています。

また、Electron は画面表示に Chromium を使用しているので、

パッケージサイズが大きくなってしまうという問題がありますが、

Tauri は画面表示に Webview2 を使用しているので、パッケージサイズが非常にコンパクトです。

Rust 自体も開発者から愛される言語ナンバーワンを何年も獲得し続けている言語で、

これからも注目される言語であることは間違いないでしょう。

https://v2.tauri.app

Tauri と Next.js のセットアップ

Tauri のフロントエンドに Next.js を使用するのは公式でも推奨されており、ドキュメントにも丁寧に記載されていますが、ここでも簡単に紹介しておきます。

まずは、Next.js のプロジェクトを作成します。

Next.js のプロジェクト作成

npx create-next-app@14.2.3

次に、Tauri に乗せるために Next.js を静的エクスポートする必要があるので、その設定をします。

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
    images: {
    unoptimized: true,
  },
};

export default nextConfig;

次に、package.json に tauri コマンドを追加します。

package.json
"scripts": {
  "tauri": "tauri"
}

これで Next.js 側の初期設定は完了です。

Next.js は現在最新バージョンが 15 系ですが、
Tauri 公式では 14 系なら正確に動く、というような記載がなされています。

Next.js15 や React19 から新たな機能が追加されていますが、まだ周辺ライブラリの対応が間に合っておらず、依存関係の問題で思わぬ落とし穴がある可能性もあるので

まだしばらくは 14 系を使用していくのが無難でしょう。

実際、私はこのあたりを深く考えず Next15 を使用したため、後で後悔します

Tauri のセットアップ

Rust の環境構築は済んでいることを前提とします。

Tauri の環境構築のついてはターゲットプラットフォームによって異なるため、

この記事では筆者が Windows で開発したので、Windows の環境構築についてのみ紹介します。

Tauri は開発に Microsoft C++ Build ToolsMicrosoft Edge WebView2 を使用します。これらは両方とも Windows での開発に必要です。

Microsoft C++ Build Tools のインストール
  1. Microsoft C++ Build Tools インストーラーをダウンロードして開き、インストールを開始します。
  2. インストール中に、「C++ によるデスクトップ開発」オプションをチェックします。

https://visualstudio.microsoft.com/ja/visual-cpp-build-tools/

WebView2 に関しては、Windows 11 以降であれば、デフォルトでインストールされているので、特にインストールする必要はありません。

Tauri のプロジェクト作成

npm コマンドで Tauri のプロジェクトを作成します。

npm create tauri-app@latest

開発環境の構成を聞かれるので、Next.js を使う場合は TypeScript,React を選択しておきましょう。

開発環境が整ったので dev モードで起動してみます。

npm install
npm run tauri dev

これで tauri のセットアップは完了です。

バックエンドの実装

Rust 初心者なので、ビジネスロジックが構築できなければ元も子もないので、

まずはビジネスロジックを構築していきました。

今回のアプリは DB を使用しないので、ビジネスロジックは非常にシンプルです。

(シンプルすぎて最終的にソースコードの 10%しか Rust のコードはありません。。)

お恥ずかしいですが、少し紹介します。

画像バイナリの受け取り、並列処理

今回のアプリの目的は PNG,JPG,WEBP などの画像ファイルを AVIF,WEBP に変換することです。

フロントエンドからユーザーに選択された画像ファイルをバイナリに変換して、

バックエンドの方に渡す構成で作っていきました。

まずフロントの TypeScript から引数で受け取る値の型を構造体で定義する必要があります。

この構造体の定義が TypeScript から渡される型の定義と異なる場合、エラーになってしまうので気をつけましょう。

src/src-tauri/src/lib.rs
#[derive(Debug, Serialize, Deserialize)]
struct FileInfo {
    file_name: String,
    file_name_with_extension: String,
    mime_type: String,
}

#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
enum ExtensionType {
    Webp,
    Avif,
}

https://zenn.dev/junkor/articles/98f2f0e6a9ea23

次に、フロントから渡された画像ファイル群のバイナリデータを受け取り、並列で変換を行うロジックを実装します。

フロントから Rust の関数を呼び出すには、#[tauri::command] というマクロを使用します。

src/src-tauri/src/lib.rs
impl ExtensionType {
    fn get_extension_str(&self) -> &str {
        match self {
            ExtensionType::Webp => "webp",
            ExtensionType::Avif => "avif",
        }
    }
}

#[tauri::command]
fn convert(
    files_binary: Vec<Vec<u8>>,
    file_infos: Vec<FileInfo>,
    extension_type: ExtensionType,
    quality: u8,
) -> Result<(Vec<PathBuf>, String), Error> {
    // 一時ディレクトリを取得
    let temp_dir = std::env::temp_dir();
    // 一意なディレクトリ名を生成
    let unique_dir_name = uuid::Uuid::new_v4().to_string();
    // 一時ファイルを保存する一時ディレクトリを作成
    let output_dir = temp_dir.join(format!("tavif_{}", unique_dir_name));
    std::fs::create_dir_all(&output_dir)?;

    let output_paths = files_binary
        .par_iter()
        .zip(file_infos)
        .enumerate()
        .filter_map(|(_idx, (file_binary, file_info))| {
            let output_path = output_dir.join(format!(
                "{}.{}",
                file_info.file_name,
                extension_type.get_extension_str()
            ));
            match extension_type {
                ExtensionType::Webp => {
                    encode_to_webp(file_binary, &output_path, quality).ok()?;
                }
                ExtensionType::Avif => {
                    encode_to_avif(file_binary, &output_path, quality).ok()?;
                }
            };
            Some(output_path)
        })
        .collect::<Vec<_>>();

    Ok((output_paths, output_dir.to_str().unwrap_or("").to_string()))
}

Rust には rayon という簡単に並列処理を行うためのライブラリがあり、

このコードでは、par_iter() というメソッドを使用して、ファイルのバイナリデータを並列で処理しています。

これによって高速で画像変換を行うことが可能になります。

ファイルの圧縮、変換ロジック

インタプリタ言語しか触ったことがない人間が Rust でバイナリを自力で扱うのは大変です。

Rust には crates.ioというライブラリ管理サイトがあり、

ここには便利なライブラリが沢山公開されています。

https://crates.io

今回は crates.io で公開されている画像変換ライブラリの中から、

変換効率が優れているimagewebpというクレートを使用して変換ロジックを実装しました。

src/src-tauri/src/lib.rs
fn encode_to_avif(img_binary: Vec<u8>, output_path: &str, quality: u8) -> Result<()> {
    let img = image::load_from_memory(&img_binary)?;
    let img = img.to_rgb8();
    let file = File::create(output_path)?;

    let writer = BufWriter::new(file);
    let encoder = AvifEncoder::new_with_speed_quality(writer, 5, quality);
    encoder.write_image(
        img.as_raw(),
        img.width(),
        img.height(),
        image::ColorType::Rgb8,
    )?;
    Ok(())
}

fn encode_to_webp(img_binary: Vec<u8>, output_path: &str, quality: u8) -> Result<()> {
    // 引数のqualityはu8型で統一しているため、ここでf32型に変換する
    let quality = quality as f32;

    let img = image::load_from_memory(&img_binary)?;
    let img = img.to_rgba8();
    let (width, height) = img.dimensions();

    let encoded = webp::Encoder::new(&*img, webp::PixelLayout::Rgba, width, height)
        .encode(quality)
        .to_vec();

    // エンコードされたデータをファイルに書き込む
    std::fs::write(output_path, encoded)?;
    Ok(())
}

はい、ビジネスロジックはたったこれだけです。

他にも細かなロジックはいくつかありますが、メインはこれらです。

お気づきかもしれませんが今回は Rust というよりは Tauri の学習みたいになってしまってます。(もっと Rust のコードを書くつもりだった)

気にせず実装を続けていきましょう。

フロントエンドの実装

フロントエンドには前述の通り Next.js を使用しました。

フロント側のざっくりとした役割としては、

  • ユーザーに選択された画像ファイルをバイナリに変換して、バックエンドに渡す
  • バックエンドから変換された画像の結果とパスを受け取り、ユーザーに表示する

というものです。

Tauri にはフロント側の JS でファイルシステムに高速にアクセスすることができる Rust の API が初めから沢山用意されているので、これらを有効活用していくことで簡単にネイティブアプリっぽく作ることができます。

ドラッグアンドドロップで画像ファイルを選択

React でドラッグアンドドロップファイル選択機能を実装するとなると、react-dropzone あたりのライブラリを使用するのが一般的かと思われます。

しかし、Tauri では react-dropzone などは上手く動作しません。

その代わり、Tauri の api でドラッグアンドドロップ機能が用意されているので、こちらを使用していきます。

簡単のために本質の部分のみ紹介しますが、以下のコードだけでファイルのドロップ操作を検知して、ファイルのパスを取得することができます。

example.ts
import { listen } from "@tauri-apps/api/event";

listen("tauri://drag-drop", (event) => {
    const paths = (event.payload as { paths: string[] }).paths;
});

注意点としては、このドロップ操作検知範囲はアプリのウィンドウ全体に及びます。

特定の範囲内でのみ有効化したい場合は、公式ではそのような設定は用意されていないので自力で実装する必要がありますので注意です。

https://v2.tauri.app/reference/javascript/api/namespaceevent/#listen

ファイル選択ダイアログ

D&D だけでは UX が良くないので、ファイル選択ダイアログを表示する必要があります。

Tauri にはもちろんファイル選択ダイアログの API が用意されているので、こちらを使用していきます。

example.ts
import { open } from "@tauri-apps/plugin-dialog";

export async function openDialog(
  setFilePaths: (paths: string[]) => void,
  prevPaths: string[]
): Promise<void> {
  const paths: string[] | null = await open({
    title: "Select Files",
    multiple: true,
    directory: false,
    filters: [
      {
        name: "Image Files",
        extensions: ["jpg", "jpeg", "png", "webp"],
      },
    ],
  });
  if (!paths) return;
  const allPaths = [...prevPaths, ...paths]; // 既存のパスと新しいパスを結合
  const uniquePaths = Array.from(new Set(allPaths)); // 重複を排除
  setFilePaths(uniquePaths); // 計算された配列を直接渡す
}

このようにして open 関数を使えば、簡単にダイアログのタイトルから有効なファイル形式まで指定できるので、とても簡単です。🦀

Rust 側で作成した関数を呼び出す

バックエンドの実装編で Rust で作成した convert 関数をフロント側から実行していきます。

Tauri では、Rust の関数をフロント側から呼び出すために、invokeという関数を使用します。

example.ts
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";

export async function convert(
  setIsProcessing: (isProcessing: boolean) => void,
  filePaths: string[],
  quality: number,
  extensionType: string,
  fileInfos: FileInfo[],
  setProcessedFilePaths: (processedFilePaths: string[]) => void,
  setTabSelected: (tabSelected: "output" | "input") => void,
  setDialog: (dialog: React.ReactNode) => void,
  setOutputTempDir: (outputTempDir: string) => void
): Promise<React.ReactNode | null> {
  setIsProcessing(true);

  // 選択されたファイルのパスからバイナリデータを取得
  const binarys = await readFileAsync(filePaths);
  // バイナリデータをバックエンドに渡すためのデータを作成
  const sendData = await createSendData(binarys);

  // バックエンドのconvert関数を呼び出す
  const [result, outputTempDir] = await invoke<[string[], string]>("convert", {
    filesBinary: sendData,
    fileInfos: fileInfos,
    extensionType: extensionType,
    quality: quality,
  });
  setOutputTempDir(outputTempDir); // 一時ディレクトリのパスを保存
  setProcessedFilePaths(result); // 変換されたファイルのパスを保存
  setIsProcessing(false); // 処理中フラグをfalseにする
  setTabSelected("output"); // タブをoutputにする
}

async function readFileAsync(filePaths: string[]): Promise<Uint8Array[]> {
  const binarys = await Promise.all(filePaths.map(async (filePath) => {
    const res = await readFile(filePath);
    return res;
  }));
  return binarys;
}

async function createSendData(binarys: Uint8Array[]) {
  return binarys.map((uint8Array) => {
    return Array.from(uint8Array);
  });
}

エラーハンドリングなどの処理の部分は省略していますが、これでバックエンド実装編で作成した convert 関数を呼び出しています。

基本的にはこのようにして Rust と TS の間でやり取りしていきます。

もちろん、Rust から TS の関数を呼び出すことも可能ですが、今回作ったアプリでは使わなかったので、ここでは割愛します。

個人的にハマったポイント

ローカル画像ファイルをプレビューする際のセキュリティ問題

このアプリでは画像が選択された際などに、選択されたローカルの画像ファイルをプレビュー表示する機能があります。

しかし、Tauri では CSP(Content Security Policy)によるセキュリティ上の制限があるため、ローカルの画像ファイルを img タグの src 属性に直接指定することができません。

この問題を解決するには、Tauri が提供しているconvertFileSrcという関数を使用して、src 属性に指定したいパスを変換する必要があります。

example.ts
import { convertFileSrc } from "@tauri-apps/api/path";

const src = convertFileSrc(filePath);

また、これに伴って CSP とアセットプロトコルの設定も必要です。
tauri.conf.jsonに以下のように設定します。
scopeにはローカルファイル読み込みの権限を許可したディレクトリを指定します。

tauri.conf.json
"security": {
  "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost",
  "assetProtocol": {
        "enable": true,
        "scope": ["$TEMP", "$HOME"]
      }
}

このようにして、ローカルの画像ファイルパスを img タグの src 属性に指定することができます。

React ライブラリが React19 に対応できていない件

記事冒頭でも触れましたが、安易に Next.js15 系を使用してしまったために、このような問題が発生してしまいました。

具体的には UI ライブラリとして使用しているAnt Designが React19 に完全には対応できていないため、一部の UI コンポーネントで動作がおかしくなってしまいました。

一応 Ant Design 公式サイトでは React19 に対応するパッチが公開されていますが、
完全には対応していないようです。

https://ant.design/docs/react/v5-for-19

結局、動作がおかしいコンポーネントの部分は TailwindCSS で自力で実装しました。(モーダルダイアログなど)

React に限らず言えることですが、ライブラリを使用する際は必ず依存関係や動作環境が問題ないか精査してから使用するようにしましょう。(戒め)

GitHub Actions でビルドするまでが難しい!

一応TauriはGitHub Actionsでビルドすることで、一つの環境でマルチプラットフォーム対応が可能だと謳っています。

https://github.com/tauri-apps/tauri-action

当初このアプリは Windows/Mac/Linux のビルドを GitHub Actions で行い、マルチプラットフォーム対応する予定だったのですが、

実際はgithub workflowの設定が難しく、ビルドを通すだけでも一苦労な上に、
Macで実機確認したところ上手く動作しませんでした。(Linuxに関しては確認すらしてない)

結局、Windowsのみのビルドでリリースすることにしました。

私自身の知識不足によるところが大きいことは確かなのですが、
ネット上でも苦しんでいる方を多く見かけたので、そこそこ難しい問題なのかもしれません。
有識者の方々からのご意見・お叱りお待ちしております。

またTauriでアプリを作ることがあれば、このあたりを再チャレンジしてみたいと思います。

今回のアプリ開発を振り返って

低級言語初心者がRust/Tauriを使用してアプリを作ってみました。

色々苦しんだことはありますが、総評としてはWeb技術に慣れている人がRust/Tauriを使用してアプリを作るのはとても快適だと思いました。

Tauriでは公式から多くのapiが既に用意されていて、思っていたよりも自力でRustコードを書いている時間が少なかったです。

簡単なアプリならほぼフロントエンドの知識だけで作れてしまうのではないでしょうか。

ただ、Rustの言語としての難易度は噂に違わず非常に高いです。

私もまだRust初心者の域を出られそうにありません😇

しかし、低レイヤーを制御している感覚はとても楽しいです。

Go言語も気になっているので、これからも技術のキャッチアップを続けていきたいと思います!

ここまで長くなりましたが、最後まで読んでいただきありがとうございました!

それではまた👋

Discussion