常駐アプリやエディターは、アイコンをもう一度ダブルクリックされたら、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 つが多重起動です(システムトレイ(タスクトレイ)に常駐させる)。
