別のウィンドウ作成時にデータを渡す

新しく開くウィンドウへ初期データを渡す 3 つの方法(URL クエリ、emitTo のハンドシェイク、Rust の initialization_script)と、作成直後の送信が届かない原因、複数開いたときの取り違えの防ぎ方。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約9分 win-019
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. URL のクエリで ID を渡す
  4. イベントで渡す(ハンドシェイク)
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

一覧で選んだ文書を編集ウィンドウで開く、設定画面に今の値を渡すなど、新しく開くウィンドウに初期データを渡したい場面はよくあります。新しいウィンドウは別のページとして一から読み込まれるので、親の JS の変数は見えません。渡し方は「URL のクエリ」「イベントのハンドシェイク」「Rust の初期化スクリプト」の 3 通りです。ウィンドウを開く手順は 新しいサブウィンドウを開く を前提とし、ここでは受け渡しの部分と、作成直後に送ったデータが届かない、複数開くと取り違えるといった失敗の避け方を説明します。モーダル として開く場合も渡し方は同じです。

前提条件

プラグインは不要です。JS からウィンドウを作る権限 core:webview:allow-create-webview-window は core:default に含まれないので追加します。イベントの送受信(listen / emit / emitTo)は core:event:default に含まれるので core:default で足ります。見落としやすいのは、子ウィンドウ側で listen や emitTo を呼ぶには、子のラベルも capability の windows に入っている必要があることです。ラベルに ID を付けて複数開くなら "editor-*" のようにワイルドカードで書きます。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main and editor windows",
  "windows": ["main", "editor-*"],
  "permissions": [
    "core:default",
    "core:webview:allow-create-webview-window",
    "core:window:allow-set-focus"
  ]
}

3 つの方法は次のように使い分けます。

方法渡せるもの向いている場面
URL のクエリ短い文字列(ID など)子が ID を元に自分でデータを読み込む
イベントのハンドシェイクJSON にできる値親の手元にあるデータをそのまま渡す
Rust の初期化スクリプトJSON にできる値最初の描画より前に必要、再読み込みでも消したくない

1. フロントエンドから実装する (TypeScript)

URL のクエリで ID を渡す

いちばん簡単なのは、開くページの URL にクエリを付ける方法です。url の ? 以降は開発時でもビルド後でも子のページに届き、子は location.search から読みます。値は文字列なので、数値は子で変換します。ラベルにも ID を入れておくと、同じ文書を 2 回開こうとしたときに 既存のウィンドウを探して前面に出す 処理が書きやすくなります。

import { WebviewWindow } from '@tauri-apps/api/webviewWindow';

// 親: ID をクエリに載せて開く
export function openEditorById(id: number) {
  return new WebviewWindow(`editor-${id}`, {
    url: `editor.html?id=${encodeURIComponent(String(id))}`,
    title: `文書 ${id}`,
    width: 640,
    height: 480,
  });
}
// 子 (editor.html のスクリプト): クエリから ID を読む
const raw = new URLSearchParams(location.search).get('id');
const id = raw === null ? NaN : Number(raw);
if (Number.isNaN(id)) {
  document.body.textContent = '文書 ID が指定されていません';
} else {
  console.log('文書を読み込む:', id);
}

イベントで渡す(ハンドシェイク)

親の手元にあるオブジェクトを渡すならイベントを使いますが、new WebviewWindow() の直後や tauri://created の時点で emitTo() しても届きません。ウィンドウが作られただけで、子のページはまだ読み込み中で listen() が登録されていないからです。そこで、子が受け取る準備を終えてから「準備できた」と親に知らせ、親はそれを合図にデータを送ります。

親は起動時に 1 回だけ editor-ready を待ち受け、ラベルごとに渡すデータを覚えておきます。こうすると子を再読み込みしたときも同じ合図が来て、データを送り直せます。イベントには送り元のラベルが入らないので、子は自分のラベルをペイロードに入れて知らせます。

import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { emitTo, listen } from '@tauri-apps/api/event';

type Doc = { id: number; title: string; body: string };

const pending = new Map<string, Doc>(); // 子のラベル → 渡すデータ

// 子からの「準備できた」を、アプリの起動時に 1 回だけ登録しておく
await listen<{ label: string }>('editor-ready', async ({ payload }) => {
  const doc = pending.get(payload.label);
  console.log('ready:', payload.label);
  if (doc) await emitTo(payload.label, 'editor-init', doc); // その子だけに送る
});

export async function openEditor(doc: Doc) {
  const label = `editor-${doc.id}`;
  pending.set(label, doc);

  const existing = await WebviewWindow.getByLabel(label);
  if (existing) {
    await emitTo(label, 'editor-init', doc); // 開いているなら送り直して前面へ
    await existing.setFocus();
    return;
  }

  const win = new WebviewWindow(label, { url: 'editor.html', title: doc.title, width: 640, height: 480 });
  await win.once<string>('tauri://error', ({ payload }) => {
    pending.delete(label);
    console.error('ウィンドウを作れなかった:', payload);
  });
  await win.once('tauri://destroyed', () => pending.delete(label)); // 閉じたら忘れる
}

子は、自分宛てのイベントだけを受け取るよう getCurrentWebviewWindow().listen() を使います。@tauri-apps/api/event の listen() は宛先を問わずすべてのイベントを受け取るため、子でそれを使うと、2 つ目の子に送ったデータで 1 つ目の表示まで書き換わります。

import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { emitTo } from '@tauri-apps/api/event';

type Doc = { id: number; title: string; body: string };

const me = getCurrentWebviewWindow();

// 自分のラベル宛て(emitTo でこのラベルを指定したもの)だけを受け取る
await me.listen<Doc>('editor-init', ({ payload }) => {
  console.log(`[${me.label}]`, payload);
  document.title = payload.title;
  const area = document.querySelector<HTMLTextAreaElement>('#body');
  if (area) area.value = payload.body;
});

// 受け取る準備ができてから親に知らせる
await emitTo('main', 'editor-ready', { label: me.label });

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

Rust でウィンドウを作るなら、WebviewWindowBuilder の initialization_script() でデータを埋め込めます。初期化スクリプトはページのスクリプトより先に実行されるので、子は読み込みの時点でデータを読めます。ハンドシェイクもイベントの権限も要らず、再読み込みのたびに実行されるのでデータが消えません。

コマンドは必ず async にします。Windows では、同期コマンドの中でウィンドウを作ると固まる既知の問題があります。

use serde::{Deserialize, Serialize};
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};

#[derive(Serialize, Deserialize)]
struct Doc {
    id: u32,
    title: String,
    body: String,
}

#[tauri::command]
async fn open_editor(app: tauri::AppHandle, doc: Doc) -> Result<(), String> {
    let label = format!("editor-{}", doc.id);
    if let Some(win) = app.get_webview_window(&label) {
        return win.set_focus().map_err(|e| e.to_string()); // 既に開いている
    }
    // JSON は JS の式としてそのまま埋め込める
    let json = serde_json::to_string(&doc).map_err(|e| e.to_string())?;
    WebviewWindowBuilder::new(&app, &label, WebviewUrl::App("editor.html".into()))
        .title(&doc.title)
        .inner_size(640.0, 480.0)
        .initialization_script(format!("window.__INIT_DATA__ = {json};"))
        .build()
        .map_err(|e| e.to_string())?;
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![open_editor])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

親はコマンドを呼ぶだけです。

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

// ウィンドウは Rust が作るので、JS にウィンドウ作成の権限は要らない
await invoke('open_editor', { doc: { id: 7, title: 'memo.txt', body: 'こんにちは' } });

子はページのスクリプトが動く前に代入された値を読みます。

type Doc = { id: number; title: string; body: string };

declare global {
  interface Window {
    __INIT_DATA__?: Doc;
  }
}

const doc = window.__INIT_DATA__;
if (doc) {
  document.title = doc.title;
  const area = document.querySelector<HTMLTextAreaElement>('#body');
  if (area) area.value = doc.body;
}

子が起動時に invoke で取りに行く方法もあります。コマンドの引数に tauri::WebviewWindow を書くと呼び出し元のラベルが分かるので、ラベルをキーにして State に置いたデータを返せます。

動作確認

npm run tauri dev で起動し、メインから openEditor() を ID 1 と 2 で続けて呼ぶと、2 つのウィンドウがそれぞれ自分の文書を表示します。メインのコンソールには合図が、子のコンソールには受け取ったデータが出ます。

[main]     ready: editor-1
[main]     ready: editor-2
[editor-1] {id: 1, title: 'a.txt', body: '…'}
[editor-2] {id: 2, title: 'b.txt', body: '…'}

子を再読み込みしても、ハンドシェイク版は親が送り直し、初期化スクリプト版は最初から値が入っているので、表示は消えません。

よくあるエラーと対処法

  • 子にデータが届かない(エラーも出ない): 作成直後や tauri://created で emitTo() しています。子の「準備できた」を待ってから送ります。
  • 2 つ目を開くと 1 つ目の表示も変わる: 子が @tauri-apps/api/event の listen() で受けています。getCurrentWebviewWindow().listen() に替えます。
  • 子で「event.listen not allowed on window "editor-1", ...」で始まるエラー: 子のラベルが capability の windows にありません。"editor-*" を足して tauri dev を再起動します。この文面はデバッグビルドのもので、リリースビルドでは「Command plugin:event|listen not allowed by ACL」だけになります。
  • 「a webview with label editor-1 already exists」: 同じラベルでもう一度作ろうとしています。getByLabel() で確かめてから作ります。
  • ID をラベルに入れたら作成に失敗する: ラベルに使えるのは英数字と - / : _ だけで、. や空白を含むと「Window labels must only include alphanumeric characters」を含むエラーになります。

OS ごとの違いと注意点

  • Windows: 同期コマンドや Rust のイベントハンドラーの中で WebviewWindowBuilder を使うと固まります。async コマンドか別スレッドで作ります。初期化スクリプトはページ内の iframe でも実行されます。
  • 共通: 初期化スクリプトはそのウィンドウで開くすべてのページで実行されます。外部サイトへ移動できるウィンドウでは移動先からも値が見えるので、トークンなどの秘密は入れません。
  • 共通: イベントのペイロードは JSON に変換して送られます。Date は文字列になり、Map や関数は渡せません。
  • 共通: URL のクエリは文字列だけで、大きなデータには向きません。ID だけ渡し、中身は子が読み込むか、イベントで渡します。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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