Single Instance プラグインで多重起動を防ぐ

tauri-plugin-single-instance で 2 回目の起動を終わらせ、既存のウィンドウを前に出して引数を受け取る。先頭に登録する理由と、開発版とインストール版がぶつかる落とし穴も示す。

プラグイン拡張 対象: Tauri 2.x 更新日: 読了目安: 約8分 plugin-011
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. コールバックで前に出し、引数をためる
  4. 先頭に登録する理由
  5. 2. フロントエンドで受け取る (TypeScript)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

常駐アプリやエディターは、アイコンをもう一度ダブルクリックされたら、2 つ目を立ち上げずに今のウィンドウを前に出すのが自然です。Single Instance プラグインは、2 回目に起動したプロセスを初期化の途中で終了させ、そのときの引数と作業フォルダーを 1 回目のプロセスのコールバックへ届けます。エクスプローラーで別のファイルを開いたときのパスも、この経路で受け取れます。Rust だけで動くプラグインで、JS の API と権限はありません。

前提条件

npm run tauri add single-instance

クレートは Cargo.toml のデスクトップ向けの節に入り、lib.rs には空のコールバック付きで登録されます(全体の流れは プラグインをインストールして有効化する)。npm パッケージと権限は追加されません。capabilities に single-instance:default を書くと、ビルドがエラーで止まります。

同じアプリかどうかは tauri.conf.json の identifier で判断されます。実行ファイルの場所やバージョンは関係ありません。

1 回目のプロセス2 回目のプロセス
プラグインの初期化待ち受けを始める1 回目に引数を送って終了する
コールバック2 回目の起動のたびに呼ばれる呼ばれない
ウィンドウと setup通常どおり作られない・動かない

1. バックエンドから実装する (Rust)

コールバックで前に出し、引数をためる

コールバックには AppHandle、引数の配列、2 回目の起動時の作業フォルダーが渡されます。配列の先頭は実行ファイル自身のパスで、memo.txt のような相対パスは作業フォルダーを基準に解決します。ウィンドウを出すには、非表示・最小化・裏に隠れている、のどれでも戻るよう show()・unminimize()・set_focus() を順に呼びます(ウィンドウにフォーカスを当てる・外す)。

引数を emit() でそのまま画面へ送ると、ページの listen() が間に合わなかった分は失われます。複数のファイルを選んで一度に開くと、1 回目の起動の直後に 2 回目が届くこともあるので、Rust 側の列にためておき、画面から取りに来てもらいます。1 回目の起動の引数(起動時のコマンドライン引数を取得する)も同じ列に入れると、受け取り方が 1 つで済みます。

use std::path::Path;
use std::sync::Mutex;

use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager};

#[derive(Clone, Serialize)]
struct Launch {
    args: Vec<String>, // 実行ファイルを除いた引数。存在するパスは絶対パスに直す
    cwd: String,       // その起動の作業フォルダー
}

/// 画面が受け取るまで、起動の引数をためておく
#[derive(Default)]
struct PendingLaunches(Mutex<Vec<Launch>>);

fn queue_launch(app: &AppHandle, argv: Vec<String>, cwd: String) {
    let args = argv
        .into_iter()
        .skip(1) // 先頭は実行ファイル自身のパス
        .map(|arg| {
            let path = Path::new(&cwd).join(&arg); // arg が絶対パスなら arg のまま
            if path.exists() { path.to_string_lossy().into_owned() } else { arg }
        })
        .collect();
    app.state::<PendingLaunches>().0.lock().unwrap().push(Launch { args, cwd });
}

/// 非表示・最小化・裏に隠れている、のどれからでも手前に出す
fn bring_main_to_front(app: &AppHandle) {
    if let Some(window) = app.get_webview_window("main") {
        let _ = window.show();
        let _ = window.unminimize();
        let _ = window.set_focus();
    }
}

/// 2 回目の起動のたびに、1 回目のプロセスで呼ばれる
fn on_second_instance(app: &AppHandle, argv: Vec<String>, cwd: String) {
    queue_launch(app, argv, cwd);
    let _ = app.emit("launch-queued", ()); // 中身は take_launches で取りに来てもらう
    bring_main_to_front(app);
}

#[tauri::command]
fn take_launches(state: tauri::State<'_, PendingLaunches>) -> Vec<Launch> {
    std::mem::take(&mut *state.0.lock().unwrap())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let builder = tauri::Builder::default();

    // ほかのどのプラグインよりも先に登録する(デスクトップ専用)
    #[cfg(desktop)]
    let builder = builder.plugin(tauri_plugin_single_instance::init(on_second_instance));

    builder
        .manage(PendingLaunches::default())
        .setup(|app| {
            // 1 回目の起動の引数も同じ列に入れる
            let argv = std::env::args_os().map(|a| a.to_string_lossy().into_owned()).collect();
            let cwd = std::env::current_dir().unwrap_or_default().to_string_lossy().into_owned();
            queue_launch(app.handle(), argv, cwd);
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![take_launches])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

トレイに常駐して main を閉じる(破棄する)アプリでは、get_webview_window("main") が None になります。その場合はコールバックでウィンドウを作り直しますが、Windows では直接作ると固まることがあるので、ウィンドウを持たない常駐アプリを作る のように別スレッドで作ります。

先頭に登録する理由

2 回目のプロセスは、このプラグインの初期化の中で終了します。プラグインは登録した順に初期化されるため、これより前に登録したものは 2 回目のプロセスでも初期化まで動きます。たとえば Log プラグインはログファイルを開き、設定によってはローテーション(古いファイルへの切り替え)まで行うので、動いている 1 回目のログに影響します。先頭に置けば、ほかのプラグインが何もしないうちに終わります。tauri add で後から入れたプラグインは先頭に差し込まれるので、そのたびに順番を確かめます。

2. フロントエンドで受け取る (TypeScript)

先に購読してから、たまっている分を取りに行きます。この順なら、その間に届いた分も取りこぼしません。take_launches は取り出した分を列から消すので、二重にも処理されません。自作のコマンドなので capability への追加は要りません。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

type Launch = { args: string[]; cwd: string };

function handleLaunch({ args, cwd }: Launch) {
  if (args.length === 0) return; // 引数なしの起動
  console.log(`起動の引数: ${args.join(' ')} / 作業フォルダー: ${cwd}`); // ここでファイルを開く
}

async function drain() {
  for (const launch of await invoke<Launch[]>('take_launches')) handleLaunch(launch);
}

await listen('launch-queued', () => void drain());
await drain();

動作確認

npm run tauri dev で起動したまま、別のターミナルで src-tauri/target/debug の実行ファイルに引数を付けて起動します(Windows の例)。

cd C:\work
C:\work\my-app\src-tauri\target\debug\my-app.exe memo.txt

コマンドはすぐに終わり、新しいウィンドウは開かずに既存のウィンドウが前に出て、DevTools のコンソールに次のように出ます。

起動の引数: C:\work\memo.txt / 作業フォルダー: C:\work

判定は identifier だけなので、インストールしたリリース版が起動していると、tauri dev のアプリはそちらに引数を渡してすぐ終わります(ウィンドウが出ず、リリース版が前に出ます)。開発中は identifier を変えた設定を重ねて起動します。データの保存先も別のフォルダーになるので、開発版のデータがリリース版と混ざらなくなります。

{ "identifier": "com.example.myapp.dev" }
# 上の JSON を src-tauri/tauri.dev.conf.json に保存して
npm run tauri dev -- --config src-tauri/tauri.dev.conf.json

よくあるエラーと対処法

  • tauri dev でウィンドウが出ず、インストール済みのアプリが前に出る: 同じ identifier のアプリが起動中です。上の --config で開発版の identifier を変えます。
  • ビルドが「Permission single-instance:default not found, expected one of」で始まるエラーで止まる: このプラグインに権限はありません。capabilities から消します。
  • 2 回目の起動の引数が画面に届かない: ページの listen() より前に emit() しています。上のように Rust 側でためます。
  • モバイル向けのビルドでクレートが見つからない趣旨のエラーになる: デスクトップ専用のクレートです。登録を #[cfg(desktop)] で囲みます。

OS ごとの違いと注意点

  • iOS / Android: 使えません。
  • Linux: 1 回目のプロセスを探すのに D-Bus を使います。Snap や Flatpak で配布するなら、その名前(既定は identifier の末尾に .SingleInstance を付けたもの)を使う許可をマニフェストに書きます。名前は Builder::new().dbus_id() で変えられます。
  • macOS: Dock のアイコンのクリックなどで起動中のアプリを開き直したときは、Rust に RunEvent::Reopen が届きます。ウィンドウを出す処理はそこでも呼びます。関連付けで開いたファイルは引数ではなく RunEvent::Opened で届きます(sys-013)。
  • バージョン違いの同時起動: features = ["semver"] を付けると、SemVer で互換のない版(1.x と 2.x など)は別のアプリとして同時に起動できます。
  • ディープリンク: Deep Link プラグインと併用するなら features = ["deep-link"] を付けます。2 回目の起動の引数が Deep Link プラグインにも渡されてから、コールバックが呼ばれます。
  • トレイ常駐のアプリ: トレイのアイコンが 2 つ並ぶ原因の 1 つが多重起動です(システムトレイ(タスクトレイ)に常駐させる)。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する