Electron Tips

## はじめに

[NTTテクノクロス Advent Calendar 2020](https://qiita.com/advent-calendar/2020/ntt-tx)の14日目担当 @geekduck です🦆

最近はGolangでAPIサーバー、React.jsでSPA、そのSPAを開くElectron製常駐アプリを作るプロジェクトに関わっています。

そのプロジェクトの中で調べた、[Electron](https://www.electronjs.org/)のTipsをいくつか紹介したいと思います。

以下のサンプルコードが動作するElectronアプリのソースコード: https://github.com/geekduck/electron-tips

### この投稿で使用している言語、ライブラリのバージョン

* Node.js v14.15.1
* npm v6.14.9
* Electron v11.0.4
* TypeScript v4.1.2

## TypeScriptでElectronアプリを作りたい&ホットリロードもしたい

### TypeScript化

最近はTypeScriptが完全にデファクトになったので、ElectronもTypeScriptで書きたいですよね。

[electron-quick-start](https://github.com/electron/electron-quick-start)をベースにTypeScript化するサンプルを載せます。


```bash
$ https://github.com/electron/electron-quick-start
$ npm i -D typescript
$ touch tsconfig.json
```

TypeScriptの設定ファイルをこんな感じで作ります。
srcディレクトリ以下のTypeScriptファイルをビルドして、buildディレクトリにJavaScriptファイルを出力します。

```json:tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "sourceMap": true,
    "strict": true,
    "outDir": "./build",
    "rootDir": "./src",
    "noEmitOnError": true,
    "typeRoots": [
      "node_modules/@types"
    ]
  }
}
```

buildディレクトリにビルドしたJavaScriptファイルが出力されるので、package.jsonのmainプロパティの値を`.build/main.js`に変更します。

```json:package.json
{
  "name": "electron-typescript-sample",
  "version": "1.0.0",
  "main": "./build/main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^11.0.3",
    "typescript": "^4.1.2"
  }
}
```

index.htmlにもビルド前のパスが書かれているので変更します。

```index.html
<!-- 17行目あたり -->
<script src="./build/renderer.js"></script>　<!-- ./renderer.js のパスをbuildディレクトリからにする -->
```

JavaScriptファイルをsrcディレクトリに移動しつつTypeScript化します。

```bash
$ mv main.js src/main.ts
$ mv preload.js src/preload.ts
$ mv renderer.js src/renderer.ts
```

```typescript:src/main.ts
import { app, BrowserWindow, Menu, Tray } from "electron"; // requireをimportに変更した
import * as path from "path";
// 以降のコードはそのままのため省略
```

```typescript:src/preload.ts
window.addEventListener('DOMContentLoaded', () => {
  const replaceText = (selector:string, text:string) => {
    const element = document.getElementById(selector)
    if (element) element.innerText = text
  }

  for (const type of ['chrome', 'node', 'electron']) {
    replaceText(`${type}-version`, (process.versions as any)[type])
  }
})
```

renderer.jsは拡張子を.tsに変更するだけでOKです。

TypeScriptをビルドしてみます。

```bash
$ ll build/
# ファイルが何もない

$ npx tsc

$ ll build/                                                                                                                                                                                
total 48
-rw-r--r--  1 user  staff   1.5K 12 10 09:24 main.js
-rw-r--r--  1 user  staff   982B 12 10 09:24 main.js.map
-rw-r--r--  1 user  staff   403B 12 10 09:24 preload.js
-rw-r--r--  1 user  staff   497B 12 10 09:24 preload.js.map
-rw-r--r--  1 user  staff   343B 12 10 09:24 renderer.js
-rw-r--r--  1 user  staff   183B 12 10 09:24 renderer.js.map
```

ビルドできていますね。

Electronを立ち上げるとちゃんと表示されます。

```bash
$ npm start
```

![スクリーンショット 2020-12-10 9.29.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16039/4795cc2f-13b2-d911-f506-e6083e9ec3b4.png)

これでElectronをTypeScriptで作れるようになりました。

### ホットリロード

次に、srcディレクトリ配下のファイルを書き換えるたびに`tsc && npm start`するのは面倒なのでホットリロード機能を追加します。

```bash
$ npm i -D electron-reload electron-is-dev npm-run-all
```

```typescript:src/main.ts
import { app, BrowserWindow, Menu, Tray } from "electron";
import * as path from "path";
import * as isDev from "electron-is-dev";

// 開発モードの場合はホットリロードする。
if (isDev) {
  require("electron-reload")(__dirname, {
      electron: require("path").join(__dirname, "..", "node_modules", ".bin", "electron"),
      forceHardReset: true,
      hardResetMethod: "exit",
  });
}
// 以降のコードはそのままのため省略
```

`package.json`のscriptsを変更して、electronの起動と、TypeScriptファイルの監視をパラレルで起動するようにします。

```json:package.json
{
  "name": "electron-tips",
  "version": "1.0.0",
  "main": "./build/main.js",
  "scripts": {
    "start": "run-p -r -l start:watch start:electron",
    "start:electron": "electron .",
    "start:watch": "tsc -w"
  },
  /* ...(省略)... */
}
```

`npm start`でアプリを起動して、適当なファイルを編集/保存するとElectronが自動でリロードされます。

![Kapture 2020-12-10 at 14.37.45.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/16039/f665b68c-76dc-1655-ed75-c1c257833cc7.gif)


## JavaScript内で実行環境がElectronかブラウザか判定する

現在関わっているPJではReact.jsでSPAを作り込んでHTTPサーバーで公開し、そのSPAに対してブラウザかElectronアプリでアクセスする構成をとっています。

Electronアプリはガワアプリとして最低限の機能(ウィンドウの表示/フォーカス、二重起動防止、常駐など)だけ作って、他の機能はSPA側で全て作るようにしています。

そのような構成の上で、ブラウザでSPAを開いた場合とElectronアプリでSPAを開いた場合にデザインを変更する必要があり、SPAのJavaScript内で実行環境がブラウザかElectronかを判定する方法がないかを調べました。

`nodeIntegration: true`を指定してWebコンテンツを開いた場合、windowオブジェクトにいくつかのAPIが追加されるため、そのAPIの有無で実行環境がElectronかを判定します。

```typescript:main.ts
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true, // レンダラープロセスのwindowオブジェクトにNode.jsのAPIを追加する。
      preload: path.join(__dirname, 'preload.js')
    }
  })
```

```javascript:SPAのJavaScriptファイル
window?.process?.versions['electron']; 
// Electronで開いた場合 -> "11.0.3"
// ブラウザで開いた場合  -> undefined
```

`nodeIntegration: true`を指定してしまうとセキュリティ的に不安が出てしまう場合は、[contextBridge](https://www.electronjs.org/docs/api/context-bridge)を使いましょう。

```typescript:main.ts
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: false,
      worldSafeExecuteJavaScript: true,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  })
```

```typescript:preload.ts
import { contextBridge } from "electron";

// レンダラープロセスのwindowにelectronというプロパティを作る。
contextBridge.exposeInMainWorld("electron", {
  isElectron: true,
});
```

```javascript:SPAのJavaScriptファイル
window?.electron?.isElectron; 
// Electronで開いた場合 -> true
// ブラウザで開いた場合  -> undefined
```

SPA側でこのプロパティを使って表示するコンテンツを切り替えれば、ブラウザで開かれた場合とElectronで開かれた場合によってSPAの動作を変えることができます。


参考:
https://github.com/electron/electron/issues/2288
https://www.electronjs.org/docs/api/context-bridge
https://www.electronjs.org/docs/api/browser-window#new-browserwindowoptions

## electron-builder紹介

Electronのアプリをビルドする場合、[electron-packager](https://github.com/electron/electron-packager)よりも[electron-builder](https://github.com/electron-userland/electron-builder)を使うのがオススメです。

electron-packagerを使って*nix系OSでWindows向けのビルドを作ろうとするとwineが必要ですが、electron-builderでは不要です。

また、electron-builderでビルドするとWindows用のインストーラーやmacOS用のdmgまで作ってくれます。

```bash
$ npm i -D electron-builder
```

package.jsonのscriptsにビルド用のスクリプトを追加します。

また、TypeScript化しているため、buildディレクトリ配下をelectron-builderのビルド対象に指定してあげます。



```json:package.json
{
  /* ...(省略)... */
  "scripts": {
    "start": "run-p -r -l start:watch start:electron",
    "start:electron": "electron .",
    "start:watch": "tsc -w",
    "build:typescript": "tsc",
    "build:installer:macOS": "electron-builder --mac --x64",
    "build:installer:windows": "electron-builder --win --x64",
    "build": "npm-run-all build:typescript --parallel build:installer:macOS build:installer:windows"
  },
  "build": {
    "files": [
        "*.html",
        "build/**/*"
    ]
  }
}
```

`npm run build`を実行するとdistディレクトリ配下に成果物が出力されます。

```bash
$ npm run build
# ダウンロードとかビルドのログとか色々でる
$ ls -al dist/
total 441144
-rw-r--r--   1 user  staff   131B 12 10 15:39 builder-effective-config.yaml
-rw-r--r--   1 user  staff    53M 12 10 15:39 electron-tips Setup 1.0.0.exe # Windows用インストーラー
-rw-r--r--   1 user  staff    58K 12 10 15:39 electron-tips Setup 1.0.0.exe.blockmap
-rw-r--r--   1 user  staff    72M 12 10 15:40 electron-tips-1.0.0-mac.zip
-rw-r--r--@  1 user  staff    74M 12 10 15:39 electron-tips-1.0.0.dmg # macOS用インストーラー
-rw-r--r--   1 user  staff    81K 12 10 15:39 electron-tips-1.0.0.dmg.blockmap
-rw-r--r--   1 user  staff   527B 12 10 15:40 latest-mac.yml
-rw-r--r--   1 user  staff   354B 12 10 15:39 latest.yml
drwxr-xr-x   3 user  staff    96B 12 10 15:39 mac/ # acOS用インストーラーで配置される.appファイル
drwxr-xr-x  21 user  staff   672B 12 10 15:39 win-unpacked/ # Windows用インストーラーで展開されるファイル群
```

## おわりに

以上、ElectronのTipsの紹介でした。参考になれば幸いです💪


明日の担当は @moris4ki です。お楽しみに🎉