🎄

Tauriでメニューバーにアプリを常駐しよう[JavaScript編]

2024/12/09に公開

はじめに

前回Tauri v2 の System Tray API を使って、メニューバーに常駐するアプリケーションを作成しました。
今回は同機能を JavaScript 側の API を使って実装してみたいと思います。

https://v2.tauri.app/learn/system-tray/
https://zenn.dev/aidemy/articles/8d8e406967d386

メニューバーにアイコンを表示

まずはメニューバーにアイコンを表示させます。
アイコンの表示は、

await TrayIcon.new(options?: TrayIconOptions);

を使うことで表示できます。
アイコンを作成する関数を作り、非同期処理の中で呼び出すようにしておきます。

この時、iconのパスを src-tauriを起点としたパスで記載する必要があります。
今回の場合、src-tauri/icons/icon.pngを参照します。

(iconを指定しない場合透明な状態で作成されるため、
メニューバーに追加されていることに気づかず、若干ハマりました。)

hooks/useTray.ts
import { useEffect } from "react";
import { TrayIcon } from "@tauri-apps/api/tray";

async function createTray(): Promise<TrayIcon> {
  const tray = await TrayIcon.new({
    id: "js_tray_icon",
    // `src-tauri`を起点にしたパス
    icon: "icons/icon.png",
  });

  return tray;
}

export function useTray() {
  useEffect(() => {
    async function setup() {
      // トレイアイコンを作成
      const tray = await createTray();
    }
    setup();
  }, []);
}

メニューの表示

続いて、メニューアイテムを表示していきます。
メニューアイテムは

await Menu.new(opts?: MenuOptions);

Menuインスタンス作成し、そのインスタンスに

await Menu.append(items: MenuItemOptions | MenuItemOptions[])

してアイテムを追加していきます。

最後に、Menuインスタンスを

await TrayIcon.setMenu(menu: Menu | Submenu | null);

とすることで、メニューバー内で機能するメニューとして利用できるようになります。

hooks/useTray.ts
// 追加
import { Menu, MenuItemOptions } from "@tauri-apps/api/menu";

// 追加
async function createMenu(): Promise<Menu> {
  // メニューを作成
  const menu = await Menu.new();

  // メニューアイテムを追加
  const menuItems: MenuItemOptions[] = [
    {
      id: "menuID1",
      text: "メニュー1",
      action: async (id) => {
        console.log(`menu "${id}" clicked`);
      },
    },
    {
      id: "menuID2",
      text: "メニュー2",
      action: async (id) => {
        console.log(`menu "${id}" clicked`);
      },
    },
  ];
  await menu.append(menuItems.map((menu) => menu));

  return menu;
}

export function useTray() {
  useEffect(() => {
    async function setup() {
      // トレイアイコンを作成
      const tray = await createTray();
      // メニューを作成
      const menu = await createMenu();
      await tray.setMenu(menu);
    }
    setup();
  }, []);
}

メニューの追加

続いて、フロントエンドの操作をメニューバーに反映させていきます。
今回はボタンをクリックした時に、入力されたタイトルがメニューバーに追加されるようにしてみます。

まず、MenuTrayIconをステートで管理し、useEffectの外でも使えるようにします。

hooks/useTray.ts
export function useTray() {
  // 追加
  const [menu, setMenu] = useState<Menu | null>(null);
  const [tray, setTray] = useState<TrayIcon | null>(null);

  useEffect(() => {
    async function setup() {
      // 省略

      // 追加
      setTray(tray);
      setMenu(menu);
    }
    setup();
  }, []);
}

続いて、ハンドラーを追加し、引数で受け取った文字列をメニューに追加できるようにします

hooks/useTray.ts
export function useTray() {
  const [menu, setMenu] = useState<Menu | null>(null);
  const [tray, setTray] = useState<TrayIcon | null>(null);

  // 追加
  async function addMenu(text: string) {
    if (!menu) return;

    // メニューアイテムを追加
    await menu.append({
      id: crypto.randomUUID(),
      text: text,
    });
    setMenu(menu);
  }

  useEffect(() => {
    async function setup() {
      // 省略
    }
    setup();
  }, []);

  // 追加
  return { addMenu };
}

また、Menuが更新された際に、TrayIconにセットしなおすようにしておきます。

hooks/useTray.ts
export function useTray() {
  const [menu, setMenu] = useState<Menu | null>(null);
  const [tray, setTray] = useState<TrayIcon | null>(null);

  // 追加
  const resetMenu = useCallback(async () => {
    if (!tray) return;
    await tray.setMenu(menu);
  }, [tray, menu]);

  async function addMenu(text: string) {
    // 省略
  }

  useEffect(() => {
    async function setup() {
      // 省略
    }
    setup();
  }, []);

  // 追加
  useEffect(() => {
    resetMenu();
  }, [resetMenu]);

  return { addMenu };
}

最後に、App.tsxを以下のように修正し、上記で作成したフックスを利用するようにします。

App.tsx
import { useState } from "react";
import "./App.css";
import { useTray } from "./hooks/useTray";

function App() {
  const { addMenu } = useTray();
  const [text, setText] = useState("");

  async function handleAddMenu(text: string) {
    await addMenu(text);
    setText("");
  }

  return (
    <main className="container">
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button onClick={() => handleAddMenu(text)}>追加</button>
    </main>
  );
}

export default App;

Tauri を

npm run tauri dev

で立ち上げて動作を確認すると、
以下のように入力したテキストをメニューバーに追加することができました。

最終的なコードは以下の通りです。

hooks/useTray.ts
import { useCallback, useEffect, useState } from "react";
import { TrayIcon } from "@tauri-apps/api/tray";
import { Menu, MenuItemOptions } from "@tauri-apps/api/menu";

async function createTray(): Promise<TrayIcon> {
  const tray = await TrayIcon.new({
    id: "js_tray_icon",
    // `src-tauri`を起点にしたパス
    icon: "icons/icon.png",
  });

  return tray;
}

async function createMenu(): Promise<Menu> {
  // メニューを作成
  const menu = await Menu.new();

  // メニューアイテムを追加
  const menuItems: MenuItemOptions[] = [
    {
      id: "menuID1",
      text: "メニュー1",
      action: async (id) => {
        console.log(`menu "${id}" clicked`);
      },
    },
    {
      id: "menuID2",
      text: "メニュー2",
      action: async (id) => {
        console.log(`menu "${id}" clicked`);
      },
    },
  ];
  await menu.append(menuItems.map((menu) => menu));

  return menu;
}

export function useTray() {
  const [menu, setMenu] = useState<Menu | null>(null);
  const [tray, setTray] = useState<TrayIcon | null>(null);

  // メニューをリセット
  const resetMenu = useCallback(async () => {
    if (!tray) return;
    await tray.setMenu(menu);
  }, [tray, menu]);

  async function addMenu(text: string) {
    if (!menu) return;

    // メニューアイテムを追加
    await menu.append({
      id: crypto.randomUUID(),
      text: text,
    });
    setMenu(menu);
  }

  useEffect(() => {
    async function setup() {
      // トレイアイコンを作成
      const tray = await createTray();
      // メニューを作成
      const menu = await createMenu();
      await tray.setMenu(menu);

      setTray(tray);
      setMenu(menu);
    }
    setup();
  }, []);

  useEffect(() => {
    resetMenu();
  }, [resetMenu]);

  return { addMenu };
}

App.tsx
import { useState } from "react";
import "./App.css";
import { useTray } from "./hooks/useTray";

function App() {
  const { addMenu } = useTray();
  const [text, setText] = useState("");

  async function handleAddMenu(text: string) {
    await addMenu(text);
    setText("");
  }

  return (
    <main className="container">
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button onClick={() => handleAddMenu(text)}>追加</button>
    </main>
  );
}

export default App;

まとめ

前回に引き続き、Tauri v2 の System Tray API について紹介しました。
フロントエンドから操作できる API が追加されたことで、
「データフェッチしてきたものをメニューバーに追加」などのユースケースがより簡単に実装できるようになったと感じています。
個人的には Tauri は触っていてとても楽しいので、今後も別の機能を紹介していければと思います。

https://v2.tauri.app/learn/system-tray/

Aidemy Tech Blog

Discussion