Electron+Vue3でマルチウィンドウを理解したい

:::note info
viviONグループでは、DLsiteやcomipoなど、二次元コンテンツを世の中に届けるためのサービスを運営しています。
ともに働く仲間を募集していますので、興味のある方は[こちら](#一緒に二次元業界を盛り上げていきませんか)まで。
:::

# Electronでマルチウィンドウを制御したい
知り合いの自転車屋さんに、商品のポップを簡単にデザインできるツールを作れないか相談を受けたので、Electronを使ったデスクトップアプリで作ってみることにしました :bike: 
ポップのプレビュー画面が必要になるのですが、その際のマルチウィンドウ制御に少し悩まされたので、自身の備忘録も兼ねて検証内容をまとめてみました。

# 検証環境
Windows10
Node：20.10.0
Vue CLI：5.0.8

# 環境構築
ある程度これに倣えば同じように検証ができるように記載しています。

今回は、VueでElectronを利用するために[Vue CLI Plugin Electron Builder](https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/#installation)を導入します。
※公式ページではVue CLIのバージョン3か4を推奨していますが、あくまで検証なのでVue CLIのバージョン5で試しています。
>Open a terminal in the directory of your app created with Vue-CLI 3 or 4 (4 is recommended).

まずはVueプロジェクトを作成します。
```sh
vue create multi-window-app
```
Vue3を選択。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/f0607d85-b0ab-3b60-9d5e-107f1e6f40b6.png)

プロジェクトディレクトリに移動しelectron-builderを追加。
```sh
cd multi-window-app
vue add electron-builder
```
Electronのバージョンは13を選択。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/9393b02e-04d8-cc69-c3c9-d3f4ee63ff53.png)

ただ、13はあまりにも古すぎるため、まだサポート期間内の29をインストールしました。
```sh
npm install electron@29.0.0
```

Electronを起動してみます。
```sh
npm run electron:serve
```
無事に立ち上がりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/819a702f-8819-23e5-3271-adbee4b1d283.png)


# 親ウィンドウから子ウィンドウを開く
まず別ウィンドウを開くところから試します。
親ウィンドウのレンダラープロセスが、メインプロセスに対して子ウインドウの起動処理を呼び出します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/5679d833-b720-aa1c-23e1-722368ab766d.png)

メインプロセスでは、レンダラープロセスからの通知を待ち受けて新しいウィンドウを開く処理を追加しています。
```javascript:background.js
// 説明に関係ない一部コードは省略しています
'use strict'

import path from "path";
import { app, protocol, BrowserWindow } from 'electron'
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
import installExtension, { VUEJS3_DEVTOOLS } from 'electron-devtools-installer'
const isDevelopment = process.env.NODE_ENV !== 'production'

protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { secure: true, standard: true } }
])

let mainWindow, subWindow;

async function createWindow() {
  // Create the browser window.
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
      contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
      preload: path.join(__dirname, 'preload.js')
    }
  })

  if (process.env.WEBPACK_DEV_SERVER_URL) {
    await mainWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
    if (!process.env.IS_TEST) mainWindow.webContents.openDevTools()
  } else {
    createProtocol('app')
    mainWindow.loadURL('app://./index.html')
  }
}

app.on('ready', async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS)
    } catch (e) {
      console.error('Vue Devtools failed to install:', e.toString())
    }
  }

  // ****** ↓ココを追加 ******
  // レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools()
  })
  // ****** ↑ココを追加 ******

  createWindow()
})
```

レンダラープロセスからメインプロセスを呼び出すためにはプリロードスクリプトというものが必要になります。
プリロードスクリプトは、ウェブコンテンツの読み込み開始前にレンダラープロセス内で実行されるコードです。レンダラーのコンテキスト内で実行はされますが、Node.jsのAPI にアクセス可能なので、レンダラーとメインプロセスの橋渡し役のようなイメージになります。
ここでは、メインプロセスの`"open-sub-window"`というチャンネルを呼び出す`ipcRenderer.invoke()`を定義しています。
```javascript:preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('myAPI', {
  // メインプロセスのサブウィンドウ起動を呼び出し
  openSubWindow: () => ipcRenderer.invoke("open-sub-window")
})
```

1点ポイントとして、Electronのビルド成果物にプリロードスクリプトを含めるため`vue.config.js`に指定が必要となります。
```javascript:vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  // コレを追加
  pluginOptions: {
    electronBuilder: {
      preload: "src/preload.js",
    },
  },
})
```

最後に、レンダラープロセスからプリロードスクリプトで定義した`openSubWindow()`呼び出すことで一通りの処理が完成です。
```vue:views/MainWindow.vue
<template>
  <button @click="openSubWindow">Open Sub Window</button>
</template>

<script>
export default {
  name: 'MainWindow',
  methods: {
    openSubWindow() {
      // レンダラープロセス (mainWindow)からサブウィンドウを開く
      window.myAPI.openSubWindow()
    }
  }
}
</script>
```

アプリを起動し子ウィンドウを開くと新しい画面が立ち上がります。
※子ウィンドウも親と同じページを指定しているので開くページの中身は変わりません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/1b661c4a-8444-7c5a-b985-fb32654f9297.png)

![無題.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/6cd54ecc-06fb-aca9-8c54-3d58256d758c.jpeg)

今回はレンダラープロセスからメインプロセスを呼び出して子ウィンドウを生成していますが、レンダラープロセスから直接子ウィンドウを開くこともできるようです。

https://www.electronjs.org/ja/docs/latest/api/window-open

Electronでは、WebネイティブのChrome WindowとElectronのBrowserWindowがペアリングされるため、JavaScriptでの`window.open()`でBrowserWindowを生成することができるとのこと。
ただ、今回はウィンドウのインスタンスをメインプロセスに集約しておいた方が扱いやすいだろうという判断で、メインプロセスからウィンドウの生成を行っています。


# 親子ウィンドウ間でデータを受け渡す
子ウィンドウを開くことができたので、次はデータの送信を試してみます。
わかりやすくするためにVue Routerを導入してページを作成し、そのページ間（親子ウィンドウ間）でデータを受け渡します。

## Vue Routerで親子ページを作成
```sh
npm install vue-router
```

ルートパス（初期画面）は親ウィンドウ、`/sub-window`を子ウィンドウとして`router/index.js`を作成します。
```javascript:router/index.js
import { createRouter, createWebHashHistory } from "vue-router";

const routes = [
  {
    path: "/",
    name: "MainWindow",
    component: () => import(/* webpackChunkName: "window" */ "../views/MainWindow.vue"),
  },
  {
    path: "/sub-window",
    name: "SubWindow",
    component: () => import(/* webpackChunkName: "window" */ "../views/SubWindow.vue"),
  },
];

const router = createRouter({
  history: createWebHashHistory(),
  routes,
});

export default router;
```

`main.js`や`App.vue`もVue Routerに合わせて修正します。
```javascript:main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router"

createApp(App).use(router).mount('#app')
```

```vue:App.vue
<template>
  <router-view />
</template>
```

メインプロセスの子ウィンドウを開く際のパスを`'sub-window'`に変更。
```javascript:background.js
// レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL + '#/sub-window')
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools()
  })
```

子ウィンドウのvueファイルを作成。
```vue:views/SubWindow.vue
<template>
  <h1>Sub Window</h1>
</template>

<script>
export default {
  name: 'SubWindow',
}
</script>
```

アプリを起動して子ウィンドウを開くと別ページが表示されるはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/5f6806e8-bf48-7f64-e4e1-bf6114ecafb6.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/898b4b10-7a05-f07b-47a9-7c971b26dc56.png)

## 親子ウィンドウ間でデータ送信
いよいよ本題の親子ウィンドウ間でのデータ送信を試します。

### プリロードスクリプトについて
先ほどはさらっと流していましたが、プリロードスクリプトという一見冗長な仕組みを使うのにはElectronのIPC通信(Inter-Process Communication)の歴史が絡んでいました。

当初のElectronにはプリロードスクリプトは存在せず、レンダラーから直でNode.jsのAPIにアクセスしていたようです。BrowserWindowのwebPreferencesに`nodeIntegration`という項目がありますが、これを`true`にしてあげることでレンダラーからでもNode.jsのAPIへのアクセスを可能にしていたんですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/f1cee6dc-1a40-6cf7-08f5-fa83cad693db.png)

しかし、レンダラーからNode.jsやElectron APIにアクセスできるというのはセキュリティ的なリスクを伴うことになるため、`contextBridge`というものが導入されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/02542074-0116-9d32-6233-f90261b8e27e.png)
`contextBridge`はメインプロセスから切り離されているため、これを経由することによって直接Node.jsやElectron APIにアクセスすることを防いでいます。コンテキストの分離を有効にするにはBrowserWindowのwebPreferencesの`contextIsolation`を`true`にしましょう。（現在はデフォルトで`true`です）

### 親ウィンドウからメインプロセスへのデータ送信
前述の`contextBridge`に、メインプロセスに対してデータを送信するための新たな関数を追加します。
```javascript:preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('myAPI', {
  // メインプロセスのサブウィンドウ起動を呼び出し
  openSubWindow: () => ipcRenderer.invoke("open-sub-window")
  // メインプロセスの 'send-event' にメッセージを送信
  sendEvent: (msgObj) => ipcRenderer.send('send-event', msgObj)
})
```

親ウィンドウのレンダラープロセスにも処理を追加。
```vue:views/MainWindow.vue
<template>
  <h1>Main Window</h1>
  <button @click="openSubWindow">Open Sub Window</button>
  <div>
    <label>メッセージ：</label>
    <input type="text" v-model="message" />
  </div>
  <button @click="sendEvent">Send Message</button>
</template>

<script>
export default {
  name: 'MainWindow',
  data() {
    return {
      message: '',
    }
  },
  methods: {
    openSubWindow() {
      // レンダラープロセス(MainWindow)からサブウィンドウを開く
      window.myAPI.openSubWindow();
    },
    sendEvent() {
      // レンダラープロセス(MainWindow)からイベントを送信する
      window.myAPI.sendEvent(this.message)
    }
  }
}
</script>
```

メインプロセスには`'send-event'`のリスナーを定義することで、IPC通信を待ち受けできます。
受け取ったデータはコンソールにログ出力してみましょう。
```javascript:background.js
// 説明に関係ない一部コードは省略しています
app.on('ready', async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS)
    } catch (e) {
      console.error('Vue Devtools failed to install:', e.toString())
    }
  }

  // レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL + '#/sub-window')
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools()
  })
  // ****** ↓ココを追加 ******
  // レンダラープロセスからのイベントをリッスンする
  ipcMain.on('send-event', (_event, msgObj) => console.log(msgObj))
  // ****** ↑ココを追加 ******
  createWindow()
})
```

アプリを起動してメッセージを入力し送信！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/10128137-c9fe-b622-4925-7c038d5080df.png)

うまくいけばターミナル側のコンソールにメッセージの内容が表示されているはずです。
```sh
こんにちは
```

### メインプロセスから子ウィンドウにデータ送信

最後にメインプロセスから子ウィンドウのレンダラーにデータを送信します。
contextBridgeにメインプロセスからのIPC通信を待ち受ける関数を追加します。
```javascript:preload.js
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('myAPI', {
  // メインプロセスのサブウィンドウ起動を呼び出し
  openSubWindow: () => ipcRenderer.invoke("open-sub-window"),
  // メインプロセスの 'send-event' にメッセージを送信
  sendEvent: (msgObj) => ipcRenderer.send('send-event', msgObj),
  // ****** ↓ココを追加 ******
  reciveEvent: (listener) => ipcRenderer.on('recive-event', listener)
  // ****** ↑ココを追加 ******
})
```

子ウィンドウ側ではcontextBridgeのリスナーを購読します。
配信されたデータを画面表示するようにしてみます。
```vue:views/SubWindow.vue
<template>
  <h1>Sub Window</h1>
</template>

<script>
export default {
  name: 'SubWindow',
  data() {
    return {
      message: '',
    }
  },
  mounted() {
    window.myAPI.reciveEvent((_event, msgObj) => {
      // メインプロセスからのイベントリッスン
      this.message = msgObj;
    })
  }
}
</script>
```

メインプロセスでは、子ウィンドウの`contextBridge`に対してデータを送信する処理を追加しました。
```javascript:background.js
// 説明に関係ない一部コードは省略しています
app.on('ready', async () => {
  if (isDevelopment && !process.env.IS_TEST) {
    // Install Vue Devtools
    try {
      await installExtension(VUEJS3_DEVTOOLS)
    } catch (e) {
      console.error('Vue Devtools failed to install:', e.toString())
    }
  }

  // レンダラープロセスから 'open-sub-window' チャンネルへ着信
  ipcMain.handle("open-sub-window", () => {
    if (subWindow) return;
    // 子ウィンドウを作成
    subWindow = new BrowserWindow({
      title: "Sub Window",
      webPreferences: {
        nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION,
        contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION,
        preload: path.join(__dirname, "preload.js"),
      },
    });
    // 子ウィンドウ用 HTML
    subWindow.loadURL(process.env.WEBPACK_DEV_SERVER_URL + '#/sub-window')
    if (!process.env.IS_TEST) subWindow.webContents.openDevTools();
    subWindow.on('closed', () => (subWindow = null));
  })
  // レンダラープロセスからのイベントをリッスンする
  ipcMain.on('send-event', (_event, msgObj) => {
    console.log(msgObj)
    subWindow.webContents.send('recive-event', msgObj)
  })
  createWindow()
})
```

アプリから子ウィンドウを開いた状態でメッセージを送ると…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/5981b377-4375-7400-447e-64469efa01d3.png)

子ウィンドウ側のテキストinputにメッセージが表示されました:tada:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84371/375696ef-5bc6-9818-17e7-0716c05735d0.png)

### 補足
Electronの[ドキュメント](https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation)を読んでいると`contextBridge`使っていればなんでも良いという訳ではないようです。
```javascript:preload.js
// ❌ 悪いコード
contextBridge.exposeInMainWorld('myAPI', {
  send: ipcRenderer.send
})
```
上記のように形だと、引数のフィルタリングもなくIPC通信がほぼ貫通状態になってしまい、`contextBridge`を使っている意味がありません。
下記のように、1つの処理に対して1つの関数を用意して、不要なIPC通信をさせないことが大切ということですね。
```javascript:preload.js
// ✅ 良いコード
contextBridge.exposeInMainWorld('myAPI, {
  loadPreferences: () => ipcRenderer.invoke('load-prefs')
})
```

# 参考文献
https://www.electronjs.org/ja/

https://nklayman.github.io/vue-cli-plugin-electron-builder/

# 一緒に二次元業界を盛り上げていきませんか？
株式会社viviONでは、フロントエンドエンジニアを募集しています。

https://vivion.jp/recruit/

また、フロントエンドエンジニアに限らず、バックエンド・SRE・スマホアプリなど様々なエンジニア職を募集していますので、ぜひ採用情報をご覧ください。