Electronを使ってMacとWindowsで動くアプリを作ってみる

## Electronとは?
![screen.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/a50e3fb0-0554-c9ae-dbfe-4e7ab8a05504.jpeg)

Electronは、HTML、CSS、JavaScriptを使って、MacとWindowsの両方で動くデスクトップアプリを作ることができるフレームワークです。
ElectronはChromiumとNode.jsがベースとなっており、Chromeで動くページであればそのままデスクトップアプリ化させることも可能です。

作成したデスクトップアプリは、MacのAppStoreやMicrosoftのストアで公開することも可能です。

### Electronを使うメリット
- Webの言語でデスクトップアプリが作れる
- JavaScriptの資産が使える（React、Vueなども）
- 同じコードで、Mac版 Windows版が作れる

### Electronを使うデメリット
- パッケージ後の容量が大きい
- ネイティブアプリよりも実行速度が遅い

### Electoronが使われているアプリ
- VSCode
- Atom
- Facebook Messenger
- Slack
- Notion

## 環境構築
Electoronを使うには、Node.jsとnpmをインストールする必要があります。
今回はnodebrewを使ってインストールしてみます。

### nodebrewのインストール
Homebrewでnodebrewをインストールします。

```
$ brew install nodebrew
```

次に、nodebrewを使って、安定版のNode.jsをインストールします。
インストール先のディレクトリがないとエラーとなってしまうため、~/.nodebrew/srcにディレクトリを作成してから、インストールを実行します。

```
$ mkdir -p ~/.nodebrew/src
$ nodebrew install stable
```

nodebrew lsでインストールされたバージョン確認します。

```
$ nodebrew ls            
v16.14.2
current: none
```

currentがnoneとなっているので、バージョンを選択します。

```
$ nodebrew use v16.14.2
```

もう一度、nodebrew lsを実行します。

```
# nodebrew ls
v16.14.2
current: v16.14.2
```

currentに選択したバージョンが表示されました。


### Pathを通します
```
$ vim ~/.zshrc
```

以下を追加します。

```
export PATH=$HOME/.nodebrew/current/bin:$PATH
```

.zshrcを再読み込みします。
```
$ source ~/.zshrc
```

nodeとnpmコマンドが使えるようになります。
```
$ node -v
v16.14.2

$ npm -v 
8.5.0
```

## Electronを動かしてみる

### Electronのインストール
Electronのソースコードを置くディレクトリを作成して、npmパッケージを初期化します。
```
$ mkdir electron && cd electron
$ npm init
```

対話形式で色々と聞かれますが、とりあえず全てEnterでデフォルトで進めます。

```
$ npm install --save-dev electron
```

npm initを実行すると、プロジェクトのルートディレクトリに、package.jsonが追加されます。

```json:package.json
{
  "name": "electron",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^18.0.1"
  }
}
```
package.jsonの"scripts"の欄に、Electronの起動時に必要なコマンドを追加します。
```"start": "electron ."```を追加すれば動くようです。

```json:package.json
{
  "name": "electron",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "electron ." # この一文を追加
    "test": "echo \"Error: no test specified\" && exit 1",
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^18.0.1"
  }
}
```
### index.jsを用意する
package.jsonのmainのフィールドに選択したファイルが最初に読み込まれるjsファイルです。
今回は、初期化時に```index.js```が設定されています。

ルートディレクトリにindex.jsを作成します。
このファイルに、起動時にウィンドウを開いたり、読み込むhtmlファイルを指定する処理を追加していきます。

```javascript:index.js
const { app, BrowserWindow } = require('electron')

const createWindow = () => {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
  })
  mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
  createWindow()
})

```

index.jsの```mainWindow.loadFile('index.html')```を変更すれば、メインのwindowとして開かれるhtmlの置き場所を変更できます。
今回はデフォルトのまま進めますので、ルートディレクトリに```index.html```を用意します。

### index.htmlを用意する
お馴染みのHello World!のテンプレートを用意します。
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Hello World!</title>
  </head>
  <body>
    <h1>Hello World!</h1>
  </body>
</html>
```

### アプリを起動してみる
これで、Electoronを動かす準備が完成しました🎉
以下のコマンドで起動してみましょう。
```
$ npm start
```
そうすると、Electronのウィンドウが開き、無事Hello Worldのタイトルが表示されました。
![electron.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/9b48c97a-3a77-4b49-64d8-3a7cb49897e5.jpeg)
意外とあっさり起動できましたね。
続けて、メインの処理を書いていきます。

## HTMLを編集してみる
ブラウザと同じようにindex.htmlファイルがElectronのアプリ上で描画されるため、全てのHTMLタグはもちろん、CSSでデザインを整えたり、JavaScriptを実行することもできます。

試しに、以下のようなHTMLを用意しました。
``` html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>メモ帳</title>
    <style>
        h1 {
            text-align: center;
            font-size: 18px;
            margin-top: 23px;
        }
        textarea {
            width: 340px;
            height: 380px;
            margin: 0 auto;
            display: block;
            border: 2px dashed #ccc;
            border-radius: 16px;
            padding: 16px;
            resize: none;
        }
        button {
            width: 370px;
            display: block;
            margin: 16px auto 0;
            border-top: none;
            border-left: none;
            border-right: none;
            border-bottom: 4px solid #00000012;
            background: #5ca0e6;
            color: white;
            font-weight: bold;
            height: 34px;
            border-radius: 6px;
            cursor: pointer;
        }
    </style>
    <script>
        function onClickHandler() {
            alert("保存機能はまだできてません！");
        }
    </script>
  </head>
  <body>
    <h1>メモ帳</h1>
    <textarea>きょうはハンバーグをたべました。</textarea>
    <button onclick="onClickHandler()">保存</button>
  </body>
</html>
```

ファイルの変更を適用するには、再度```npm start```する必要があります。
```
$ npm start
```

アプリが開くと、htmlの変更が反映されました。
html内に追加したCSSがきちんと反映されています。
![css_check.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/2b8cadc3-f626-6feb-eed0-44f3d3bdbf4f.jpeg)

保存ボタンをクリックすると、JavaScriptが実行され、無事にアラートが表示されました。
![js_check_.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/88b4d13d-0330-46e7-3faa-c80a2b344780.jpeg)

テキストの保存機能を作ろうかと迷いましたが、先にパッケージ化できることをテストしてみます。

## MacOS用アプリ、Windows用アプリとして書き出す
上記の段階でもローカルでの実行はできますが、他人に配布して起動できるようにするには、.appファイルや、.exeファイルとして書き出す必要があります。
今回は、Electron Forgeを使ってみます。

### Electron Forgeをインストール
Electron Forgeを開発用のdependencyとしてインストールします。その後、importコマンドを実行することで、初期設定が完了です。
プロジェクトのルートディレクトリで以下を実行します。

```
$ npm install --save-dev @electron-forge/cli
$ npx electron-forge import
```


### アプリを書き出す
```npm run make```コマンドで、書き出しが始まります。
```
$ npm run make
```

### outディレクトリにファイルが保存されます
![output.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/965139c4-b24b-be6d-a123-82feae16e97e.jpeg)
名称やアイコン画像を変更していませんので、デフォルトの表示となっていますが、書き出しに成功しました。
生成されたファイル```- out/electron-darwin-x64/electron.app```を実行してみると、見事に作成したアプリが開きました。

![css_check.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2528392/2b8cadc3-f626-6feb-eed0-44f3d3bdbf4f.jpeg)

容量を確認してみると196MBと、ペラペラのアプリにしては意外と大きくなりました。
Mac版のChromeをダウンロードしてみるとファイルサイズは約180MBでしたので、Chromiumを使ったElectronのファイルサイズが大きくなってしまうのは仕方がなさそうです。

## Electronで使える便利なAPI

#### autoUpdater
アプリを自動でアップデートするのに使える。

#### clipboard
クリップボード（コピーやペースト）の機能が使える。
```javascript
const { clipboard } = require('electron')
clipboard.writeText('Example string', 'selection')
console.log(clipboard.readText('selection'))
```

#### crashReporter
クラッシュレポート（アプリのフリーズや突然終了などのレポート）をリモートのサーバーに送れる。
```javascript
const { crashReporter } = require('electron')
crashReporter.start({ submitURL: 'https://your-domain.com/url-to-submit' })
```

#### dialog
システムダイアログ、ファイルを開いたり、保存したり、アラートを出したりできる。

```javascript
const { dialog } = require('electron')
console.log(dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] }))
```

#### globalShortcut
グローバル（キーボードフォーカスが当たっていない状態も含む）でのキーボード入力を検知できる。
```javascript
const { app, globalShortcut } = require('electron')
app.whenReady().then(() => {
  const ret = globalShortcut.register('CommandOrControl+X', () => {
    console.log('CommandOrControl+X is pressed')
  })
})
```
#### inAppPurchase
MacのAppStoreで使える課金機能。


#### Menu
上部のアプリケーションメニューを設定できる。
```javascript
const { app, Menu } = require('electron')

const isMac = process.platform === 'darwin'

const template = [
  // { role: 'appMenu' }
  ...(isMac ? [{
    label: app.name,
    submenu: [
      { role: 'about' },
      { type: 'separator' },
      { role: 'services' },
      { type: 'separator' },
      { role: 'hide' },
      { role: 'hideOthers' },
      { role: 'unhide' },
      { type: 'separator' },
      { role: 'quit' }
    ]
  }] : []),
  // { role: 'fileMenu' }
  {
    label: 'File',
    submenu: [
      isMac ? { role: 'close' } : { role: 'quit' }
    ]
  },
  // { role: 'editMenu' }
  {
    label: 'Edit',
    submenu: [
      { role: 'undo' },
      { role: 'redo' },
      { type: 'separator' },
      { role: 'cut' },
      { role: 'copy' },
      { role: 'paste' },
      ...(isMac ? [
        { role: 'pasteAndMatchStyle' },
        { role: 'delete' },
        { role: 'selectAll' },
        { type: 'separator' },
        {
          label: 'Speech',
          submenu: [
            { role: 'startSpeaking' },
            { role: 'stopSpeaking' }
          ]
        }
      ] : [
        { role: 'delete' },
        { type: 'separator' },
        { role: 'selectAll' }
      ])
    ]
  },
  // { role: 'viewMenu' }
  {
    label: 'View',
    submenu: [
      { role: 'reload' },
      { role: 'forceReload' },
      { role: 'toggleDevTools' },
      { type: 'separator' },
      { role: 'resetZoom' },
      { role: 'zoomIn' },
      { role: 'zoomOut' },
      { type: 'separator' },
      { role: 'togglefullscreen' }
    ]
  },
  // { role: 'windowMenu' }
  {
    label: 'Window',
    submenu: [
      { role: 'minimize' },
      { role: 'zoom' },
      ...(isMac ? [
        { type: 'separator' },
        { role: 'front' },
        { type: 'separator' },
        { role: 'window' }
      ] : [
        { role: 'close' }
      ])
    ]
  },
  {
    role: 'help',
    submenu: [
      {
        label: 'Learn More',
        click: async () => {
          const { shell } = require('electron')
          await shell.openExternal('https://electronjs.org')
        }
      }
    ]
  }
]

const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)
```

#### Notification
OSの通知機能が使える。通知をクリックした後の動作も指定可能。


#### powerMonitor
バッテリーが使われているか、ACアダプターで節電されているか、などが分かる。

#### systemPreferences
システム環境設定の値を取得できる。ダークモードやアクセントカラーの設定など。
```javascript
const { systemPreferences } = require('electron')
console.log(systemPreferences.isDarkMode())
```

#### TouchBar
（macのみ）タッチバーの表示をカスタムできる。


## Electronのセキュリティ面
Electronは、JavaScriptを使ってOS側に近い処理（ローカルデバイスへの接続、通知、ローカルのファイルシステムへのアクセス等）を書けます。これは非常に便利な一方で、危険な側面もあります。

例えば、悪意のあるJavaScriptが埋め込まれたサイトを脆弱性のあるElectronアプリで開くと、レンダラー側からNode.jsを経由してOSレベルの危険の操作（個人情報の流出や、ウィルスの埋め込みなど）が行われる可能性があります。

そのため、Electron v12からは、レンダラーからNode.jsが実行できないような仕組み（[Context Isolation]("https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation")）が搭載されており、レンダラーからNode.jsの機能を使う場合には、「contextBridge」というモジュールを使う必要があります。


### contextBridge
preload.js内で、レンダラーに開放したいAPIをcontextBridgeを使ってホワイトリスト形式で指定します。
以下の例は、レンダラーからメインプロセスの処理（ウィンドウのタイトルの変更）を呼び出す場合の書き方です。

```javascript:main.js
  const mainWindow = new BrowserWindow({
    webPreferences: {
      // preload.jsを読み込み
      preload: path.join(__dirname, 'preload.js')
    }
  })

  // ウィンドウのタイトルを変更
  ipcMain.on('set-title', (event, title) => {
    const webContents = event.sender
    const win = BrowserWindow.fromWebContents(webContents)
    win.setTitle(title)
  })
```

```javascript:preload.js
const { contextBridge, ipcRenderer } = require('electron')

// 表示側に開放するAPIを定義
contextBridge.exposeInMainWorld('electronAPI', {
    setTitle: (title) => ipcRenderer.send('set-title', title)
})
```

```javascript:renderer.js
// 表示側でAPIを呼び出し
window.electronAPI.setTitle('こんにちは')
```


#### レンダラーからメインプロセス(一方向)
```javascript
ipcMain.on() // main.jsで使用
ipcRenderer.send() // preload.jsで使用
```

#### レンダラーからメインプロセス(双方向)
```javascript
ipcMain.handle() // main.jsで使用
ipcRenderer.invoke() //preload.jsで使用
```

#### メインプロセスからレンダラー（一方向）
```javascript
BrowserWindow.webContents.send // main.jsで使用
ipcRenderer.on // preload.jsで使用
```

### Electronについて理解ができたら

Electronについての概要をざっくり掴めたところで、実践的にデスクトップアプリを作ってみたいと思います。
次の記事では、　実際にReactとElectoronを使ってMacとWindowsで動くToDoアプリを作成します。

https://qiita.com/udayaan/items/2a7c8fd0771d4d995b69


## 関連記事
よかったら、こちらの記事も読んでみてください。

reactでポケモン風RPGゲームを作ってみよう！戦闘画面編
https://qiita.com/udayaan/items/38680c63ed034503eac0

Electron + Reactでデスクトップアプリを作ろう！
https://qiita.com/udayaan/items/2a7c8fd0771d4d995b69

話題の最新フロントエンドフレームワーク「Astro」を使ってみた
https://qiita.com/udayaan/items/24ecb2f5f4608fc1df4c