Electron でタスクトレイを活用したアプリケーションの常駐化を行う

[Docker Desktop](https://www.docker.com/ja-jp/products/docker-desktop/) や [Slack](https://slack.com/intl/ja-jp/downloads/windows) のデスクトップクライアントアプリケーションのようにウィンドウ右上の「×」ボタン押下でメインウィンドウが消えるけど、タスクトレイ上では動いているような常駐型のアプリケーションの作り方について調べたときのメモです

この記事の中で行った作業はすべて以下のリポジトリのコミット履歴として残しています

https://github.com/imo-tikuwa/electron-tray-persist-demo

## 環境情報

今回は以下の2つの環境で動作確認をしました

**PC1**
OS：Windows 11 Pro
Node.js：v20.13.1

**PC2**
OS：Windows 10 Home
Node.js：v20.17.0

:::note warn
MacOS は手元にないのでそちらで正常に動作するかはわかりません...:sob:
:::

## 作業内容

### 1. 検証用のプロジェクト作成

検証を進めるにあたって [electron-vite](https://electron-vite.org/) のサイト上で紹介されてるスキャフォールドを利用しました

```bash:Electron のプロジェクト作成
$ npm create @quick-start/electron@latest

> npx
> create-electron

√ Project name: ... electron-tray-persist-demo
√ Select a framework: » svelte
√ Add TypeScript? ... No / Yes
√ Add Electron updater plugin? ... No / Yes
√ Enable Electron download mirror proxy? ... No / Yes

$ cd electron-tray-persist-demo
$ npm install
$ npm run dev
```

Vite の dev サーバーを起動することで Electron のデモアプリが起動できました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/350956a4-b6a4-4b74-a82f-ed54556cad42.png)

---
以下がプロジェクト内に含まれるメインプロセスに関するコードです（英文のコメントは取り除いてます）

```typescript:src/main/index.ts
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'

function createWindow(): void {
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false
    }
  })

  mainWindow.on('ready-to-show', () => {
    mainWindow.show()
  })

  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })

  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}

app.whenReady().then(() => {
  electronApp.setAppUserModelId('com.electron')

  app.on('browser-window-created', (_, window) => {
    optimizer.watchWindowShortcuts(window)
  })

  ipcMain.on('ping', () => console.log('pong'))

  createWindow()

  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})
```

### 2. タスクトレイを追加

常駐化の前にまずはタスクトレイを追加します。createWindow 関数内に以下を追記

```diff_typescript:src/main/index.ts
-import { app, shell, BrowserWindow, ipcMain } from 'electron'
+import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'

function createWindow(): void {
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false
    }
  })

+  const tray = new Tray(icon)
+  const trayMenu = Menu.buildFromTemplate([
+    {
+      label: 'アプリを表示',
+      click: (): void => mainWindow.show()
+    },
+    {
+      label: 'アプリを終了',
+      click: (): void => {
+        tray.destroy()
+        app.exit(0)
+      }
+    }
+  ])
+  tray.setContextMenu(trayMenu)

  mainWindow.on('ready-to-show', () => {
    mainWindow.show()
  })

  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })

  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}

~~~ 省略 ~~~
```

---

再度 Vite の dev サーバーを起動し、タスクトレイ内に開発中のアプリケーションのアイコンが表示されるようになりました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/43899ac3-a2ef-4e77-8a29-0de5bb967931.png)

コンテキストメニューの設定を行ったことにより右クリックから「アプリを表示」「アプリを終了」という選択肢が表示されることも確認できました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/5cbd7919-2a4f-4071-879c-8d7323d7591c.png)

### 3. 常駐化する

そもそもですが Windows の場合にデスクトップアプリのウィンドウ右上にある「×」ボタンを押した場合、通常の挙動としてアプリケーションは終了します

そのため、まずはその挙動を変更します

```diff_typescript:src/main/index.ts
function createWindow(): void {
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false
    }
  })

  const tray = new Tray(icon)
  const trayMenu = Menu.buildFromTemplate([
    {
      label: 'アプリを表示',
      click: (): void => mainWindow.show()
    },
    {
      label: 'アプリを終了',
      click: (): void => {
        tray.destroy()
        app.exit(0)
      }
    }
  ])
  tray.setContextMenu(trayMenu)
+
+  mainWindow.on('close', (e) => {
+    e.preventDefault()
+    mainWindow.hide()
+  })

  mainWindow.on('ready-to-show', () => {
    mainWindow.show()
  })

  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })

  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}
```

- createWindow 関数の中で mainWindow の close イベントの処理として以下のような内容の追記を行いました
  - デフォルトのイベントをキャンセルすること `e.preventDefault()`
  - mainWindow を非表示とすること `mainWindow.hide()`

---

この変更によってウィンドウ右上の「×」ボタン押下後、タスクバーのアイコンが消えつつもタスクトレイのアイコンは残るような状態となりました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/9316c242-5d18-40e9-b4aa-69ff46b675af.png)

↓ 「×」ボタン押下後

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/4291677d-3a0a-43c6-b51a-4c13981e372f.png)

---

Vite の dev サーバーも稼働中であることがわかります

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/933652ab-3921-416c-b38d-ece89d0a87b7.png)

---

タスクトレイで「右クリック > アプリを表示」からメインウィンドウの再表示も行えました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/347765ff-fd57-4ee6-869a-7b76c2c60d9e.png)


## おまけ

常駐型のアプリケーションを開発するにあたってのおまけ
日常的に仕事の中で使用しているアプリの挙動を参考に少しですがデモの挙動の改善を実施しました

### 1. タスクトレイのアイコン左クリックしたらウィンドウを表示する

Docker Desktop、Slack などのアプリケーションについてタスクトレイのアイコンを左クリックするとメインウィンドウが表示できるようになっていました
この挙動は Electron を使ったアプリケーションでは以下のようにタスクトレイのインスタンスに対して1行追記するだけで再現できました

```diff_typescript:src/main/index.ts createWindow関数内のコード
  const tray = new Tray(icon)
  const trayMenu = Menu.buildFromTemplate([
    {
      label: 'アプリを表示',
      click: (): void => mainWindow.show()
    },
    {
      label: 'アプリを終了',
      click: (): void => {
        tray.destroy()
        app.exit(0)
      }
    }
  ])
  tray.setContextMenu(trayMenu)
+  tray.on('click', () => mainWindow.show())
```

### 2.多重起動を防止する

常駐型のアプリケーションについて多重起動できてしまうとどのメインウィンドウがどのタスクトレイと紐づいているかの判別がパっと見できません。ということで多重起動は防止するのが良いかなと考えています
多重起動の防止は以下のようなコードの追記で達成できました

```diff_typescript:src/main/index.ts createWindow関数の直後らへん
~~~ 省略 ~~~

  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}
+
+if (!app.requestSingleInstanceLock()) {
+  console.error('Another instance is running.')
+  app.exit(0)
+}

app.whenReady().then(() => {
  electronApp.setAppUserModelId('com.electron')

  app.on('browser-window-created', (_, window) => {
    optimizer.watchWindowShortcuts(window)
  })

  ipcMain.on('ping', () => console.log('pong'))

  createWindow()

~~~ 省略 ~~~
```

Vite の dev サーバー起動中にもう1つコマンドプロンプトを立ち上げ、2つ目の dev サーバーを起動を試み `Another instance is running.` のコンソールエラー出力を得られることを確認しました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/e8cbca75-ff49-4078-a2ec-829878d4d105.png)

### 3. アプリ起動中にもう1つアプリを起動しようとしたときに起動中のアプリ側をアクティブ化＆フォーカスする

多重起動の防止の件と関連しますがアプリを「最小化していたり」「メインウィンドウを閉じて常駐化している状態」でショートカットなどからアプリを起動しようとした場合、常駐化中のアプリケーションに気づけないという問題が起きます
そのため、常駐化中のアプリケーションの状態を元にメインウィンドウをアクティブ化＆フォーカスするコードを追加します
具体的には `second-instance` というイベントを参照することで達成できました

```diff_typescript:src/main/index.ts createWindow関数内の適当な位置に追記
  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })
+
+  app.on('second-instance', () => {
+    if (mainWindow.isMinimized()) {
+      mainWindow.restore()
+    }
+    if (!mainWindow.isVisible()) {
+      mainWindow.show()
+    }
+    mainWindow.focus()
+  })

  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
```
- メインウィンドウが最小化されてたら復帰
- メインウィンドウが非表示だったら表示
- メインウィンドウをフォーカス

といったことを行っています。「最小化」「非表示」は別の状態な点について注意

### 4. メインウィンドウを閉じたときに完全に終了していないことを通知する

Electron 標準の Notification API を使用するだけで簡単に実現できました
以下のような感じで Notification をインポートしつつ、先の手順で追加した close のイベントの中に少し追記

```diff_typescript:src/main/index.ts
-import { app, shell, BrowserWindow, ipcMain, Tray, Menu } from 'electron'
+import { app, shell, BrowserWindow, ipcMain, Tray, Menu, Notification } from 'electron'

~~~ 省略 ~~~

  mainWindow.on('close', (e) => {
    e.preventDefault()
    mainWindow.hide()

+    new Notification({
+      title: 'アプリはバックグラウンドで動作中です',
+      body: 'ウィンドウはタスクトレイから再表示できます。完全に終了したい場合はタスクトレイのアイコン右クリックから「アプリを終了」でできます。',
+      icon: icon
+    }).show()
  })
```

以下は Windows 10 の PC で確認したときの様子

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/40587ef5-1fae-4da0-8d04-41fd1a15e796.png)

毎回表示されると少し邪魔に思えるかもしれないので表示の有無を設定できるようにすると尚いいかなと思います

:::note warn
ちなみに通知を表示するには「通知とアクション」の `アプリやその他の送信者からの通知を取得する` がオンになっている必要がありました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2384986/ffa9e60d-7cda-4e67-86e2-987175e371f1.png)
:::

## 最終的なコード
おまけの内容も含む最終的なコードがこちら

```typescript:src/main/index.ts
import { app, shell, BrowserWindow, ipcMain, Tray, Menu, Notification } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'

function createWindow(): void {
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false
    }
  })

  const tray = new Tray(icon)
  const trayMenu = Menu.buildFromTemplate([
    {
      label: 'アプリを表示',
      click: (): void => mainWindow.show()
    },
    {
      label: 'アプリを終了',
      click: (): void => {
        tray.destroy()
        app.exit(0)
      }
    }
  ])
  tray.setContextMenu(trayMenu)
  tray.on('click', () => mainWindow.show())

  mainWindow.on('close', (e) => {
    e.preventDefault()
    mainWindow.hide()

    new Notification({
      title: 'アプリはバックグラウンドで動作中です',
      body: 'ウィンドウはタスクトレイから再表示できます。完全に終了したい場合はタスクトレイのアイコン右クリックから「アプリを終了」でできます。',
      icon: icon
    }).show()
  })

  mainWindow.on('ready-to-show', () => {
    mainWindow.show()
  })

  mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return { action: 'deny' }
  })

  app.on('second-instance', () => {
    if (mainWindow.isMinimized()) {
      mainWindow.restore()
    }
    if (!mainWindow.isVisible()) {
      mainWindow.show()
    }
    mainWindow.focus()
  })

  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}

if (!app.requestSingleInstanceLock()) {
  console.error('Another instance is running.')
  app.exit(0)
}

app.whenReady().then(() => {
  electronApp.setAppUserModelId('com.electron')

  app.on('browser-window-created', (_, window) => {
    optimizer.watchWindowShortcuts(window)
  })

  ipcMain.on('ping', () => console.log('pong'))

  createWindow()

  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})
```

## 備考
記事内のコードについて "アプリを終了する" 系のコードは当初、 `app.exit(0)` ではなく `app.quit()` の記述をしていました。

```typescript:こんな感じ
if (!app.requestSingleInstanceLock()) {
  console.error('Another instance is running.')
  app.quit()
}
```

こちら Vite の dev サーバーとの相性が良くないのか以下のようなエラーが出てしまうため `app.exit(0)` としています（よくわかってない:sob:）

```txt:多重起動した側のコンソール出力
dev server running for the electron renderer process at:

  ➜  Local:   http://localhost:5174/
  ➜  Network: use --host to expose

start electron app...


Another instance is running.
[26248:0406/223915.753:ERROR:cache_util_win.cc(20)] Unable to move the cache: 繧｢繧ｯ繧ｻ繧ｹ縺梧拠蜷ｦ縺輔ｌ縺ｾ縺励◆縲・(0x5)
[26248:0406/223915.753:ERROR:cache_util_win.cc(20)] Unable to move the cache: 繧｢繧ｯ繧ｻ繧ｹ縺梧拠蜷ｦ縺輔ｌ縺ｾ縺励◆縲・(0x5)
[26248:0406/223915.753:ERROR:cache_util_win.cc(20)] Unable to move the cache: 繧｢繧ｯ繧ｻ繧ｹ縺梧拠蜷ｦ縺輔ｌ縺ｾ縺励◆縲・(0x5)
[26248:0406/223915.764:ERROR:disk_cache.cc(216)] Unable to create cache
[26248:0406/223915.769:ERROR:gpu_disk_cache.cc(711)] Gpu Cache Creation failed: -2
[26248:0406/223915.770:ERROR:disk_cache.cc(216)] Unable to create cache
[26248:0406/223915.770:ERROR:gpu_disk_cache.cc(711)] Gpu Cache Creation failed: -2
[26248:0406/223915.770:ERROR:disk_cache.cc(216)] Unable to create cache
[26248:0406/223915.770:ERROR:gpu_disk_cache.cc(711)] Gpu Cache Creation failed: -2
```

## 参考サイト

https://electron-vite.org/

https://zenn.dev/sprout2000/books/3691a679478de2/viewer/13547