日頃お世話になっているElectronのアプリ開発に入門してみる

# はじめに

ここ1～2年ほど，自分はElectron製のアプリにかなりお世話になっています．[こちら](https://qiita.com/y-tsutsu/items/61dd0bc60838c10b963b)でも紹介したのですが，特にVisual Studio Codeと出会えなかったら今ほど楽しくコードが書けていないかもしれません．これほどお世話になっている技術なので，自分でもElectronでアプリを作ることに挑戦してみようと思います．

# 実際に作ったアプリ

このような感じのテキストエディタを作りました．このゴールに向けて順にまとめていきます．

<img width="590" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/45310/520b6dd3-a079-e2ed-b76d-fe72bb3017a0.png">

# Electronとは？

はじめにElectronについて簡単に説明しておきます．
ElectronとはWindows / macOS / Linuxとマルチプラットフォームで動かすことができるデスクトップアプリを開発することができるフレームワークです．HTML + CSS + JavaScriptといったWeb開発の技術を使って開発できるのが特徴です．GitHubがテキストエディタのAtomを開発する過程でオープンソースとして公開されました．Electronのサイトにも紹介されていますがAtomやVisual Studio Codeの他，Slackアプリなどにも採用されています．
[https://electronjs.org](https://electronjs.org)

# 開発環境

今回はWindowsで開発しました．もちろんmacOSやLinuxでも同様に開発可能と思います．Electronの開発にはNode.jsが必要となりますので事前にインストールしましょう．
[https://nodejs.org/ja/](https://nodejs.org/ja/)

今回の自分の開発環境は以下のようになっています．

* Windows 10 Pro
* Node.js v9.11.1
* Electron v1.8.4

Node.jsをインストールしたら，確認のためターミナルから下記のようにVersionの確認を行いましょう．npmはパッケージマネージャでNode.jsとあわせてインストールされます．

```console
> node --version
v9.11.1
> npm --version
5.6.0
```

# はじめはHello, World!

まずはハローワールドというか，簡単なウィンドウを表示させることを目標とします．

## package.jsonの作成

事前に準備したnpmを使ってプロジェクトの初期化を行います．作業ディレクトリを作成し，そこで`npm init`します．対話式にいろいろと情報を設定していきますが`-y`オプションで初期値でpackage.jsonが生成されます．今回は簡単にこれで．

```console
> mkdir my_electron
> cd my_electron
> npm init -y
```

```json:package.json
{
  "name": "my_electron",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
```

## Electronのインストール

次にElectronのインストールを行います．`-D`オプションは`--save-dev`の省略でpackage.jsに記述されます．今回はローカルにインストールします．インストールしたパッケージは`node_modules\.bin`以下にインストールされます

```console
> npm i -D electron
> node_modules\.bin\electron --version
v1.8.4
```

## Welcomeウィンドウの表示

Electronのインストールが完了したらWelcomeウィンドウの表示を行ってみます．また，ここでは`npx`を使用しています．このようにすると簡単にローカルにインストールしたelectronにパスが通ります．

```console
> npx electron
```

何やらかっこいいウィンドウが表示されました．

<img width="677" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/45310/24572739-9018-5c30-4639-cea8f72c36dc.png">

## 自作ウィンドウの表示

かっこいいのはいいですが，さすがに何もやっていないので自前でウィンドウを作成して表示させます．
今回は`src`ディレクトリを作成し，その中に以下の3ファイルを用意します．

* index.html
* main.js
* package.json

ファイルの構成はこのようになっています．

<img width="119" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/45310/72d0a4dc-c1f2-9a7d-c1a0-1fd0ee9ac011.png">

### index.html

表示させるウィンドウのHTMLです．とりあえず何でもいいので簡単に用意します．試しにBootstrapも使ってみました．

```html:index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>MY ELECTRON</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4"
        crossorigin="anonymous">
</head>

<body>
    <h1>Hello, World!</h1>
</body>

</html>
```

### main.js

エントリーポイントとなるJavaScriptのコードです．ネットで検索すれば細かい記述方法の違いはありますが，下記のようなコードが見つかるはずです．またこのコードの中で上記の`index.html`を指定している個所もあります．

```js:main.js
const { app, Menu, BrowserWindow } = require('electron');
const path = require('path');
const url = require('url');

let mainWindow;

function createWindow() {
    mainWindow = new BrowserWindow({ width: 800, height: 600 });

    mainWindow.loadURL(url.format({
        pathname: path.join(__dirname, 'index.html'),
        protocol: 'file:',
        slashes: true
    }));

    // 開発ツールを有効化
    // mainWindow.webContents.openDevTools();

    Menu.setApplicationMenu(null);

    mainWindow.on('closed', () => {
        mainWindow = null;
    });
}

app.on('ready', createWindow);

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

app.on('activate', () => {
    if (mainWindow === null) {
        createWindow();
    }
});
```

### package.json

はじめに作成したファイルと同名でややこしいですが，`src`ディレクトリの中にも`package.json`を用意します．ここではエントリポイントの`main.js`を指定しています．

```json:package.json
{
  "main": "main.js"
}
```

### Hello, World!

ここまでコードを用意できたら以下のコマンドでウィンドウが表示されます．Hello, World!を表示させているだけなので分かりづらいですが，Bootstrapもきちんと適用されているようです．（フォントが変わっています．）

```console
> npx electron src
```

<img width="590" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/45310/164809a5-9715-04c2-a1f1-640c9b2c7643.png">

# テキストエディタの作成

ウィンドウの表示までできたので，次は目標に向けて簡単なアプリの作成を行います．
ここからはこちらの記事にお世話になりました．感謝です:bow:
[https://ics.media/entry/8401/](https://ics.media/entry/8401/)

## Ace.js

Ace.jsとはJavaScript製のテキストエディタです．様々なプログラミング言語のシンタックスハイライトが動く他に，構文チェック機能もあるようです．また多数のテーマにも対応しています．今回はAce.jsを使うことによって，手軽にテキストエディタのElectronアプリを作成することができています．
[https://ace.c9.io](https://ace.c9.io)

## コードの更新と追加

Hello, World!の続きで，以下の3ファイルを更新，追加します．

* index.html（更新）
* editor.js（新規）
* main.css（新規）

<img width="294" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/45310/4cf2ac48-bc62-e4e0-d001-7c0face8b866.png">

### index.html

テキストエディタとしての画面を作成します．ヘッダとフッタ，テキスト入力エリアのシンプルな構成です．またWebフォントなんかも通常のWebアプリと同じように使用することができます．

```html:index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>MY ELECTRON</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4"
        crossorigin="anonymous">
    <link rel="stylesheet" href="main.css">
</head>

<body>
    <div id="header_fixed">
        <button type="button" class="btn-sm btn-primary" id="btnLoad">
            <i class="fas fa-folder-open fa-lg fa-fw"></i>
        </button>
        <button type="button" class="btn-sm btn-primary" id="btnSave">
            <i class="fas fa-save fa-lg fa-fw"></i>
        </button>
    </div>
    <div id="footer_fixed"></div>
    <div id="input_area">
        <div id="input_txt"></div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.3.3/ace.js"></script>
    <script defer src="https://use.fontawesome.com/releases/v5.0.10/js/all.js" integrity="sha384-slN8GvtUJGnv6ca26v8EzVaR9DC58QEwsIk9q1QXdCU8Yu8ck/tL/5szYlBbqmS+"
        crossorigin="anonymous"></script>
    <script src="editor.js"></script>
</body>

</html>
```

### editor.js

主には起動時の初期化処理とファイルの読み込み・保存処理を記述します．Ace.jsのカラーテーマは`editor.setTheme()`で設定可能です．同様に構文チェック機能は`editor.getSession().setMode`で設定できます．ファイルの読み込み時などに拡張子からターゲットの言語を変更しています．


```javascript:editor.js
const fs = require('fs');
const { BrowserWindow, dialog } = require('electron').remote;

let inputArea = null;
let inputTxt = null;
let footerArea = null;

let currentPath = '';
let editor = null;

window.addEventListener('DOMContentLoaded', onLoad);

function onLoad() {
    inputArea = document.getElementById('input_area');
    inputTxt = document.getElementById('input_txt');
    footerArea = document.getElementById('footer_fixed');

    editor = ace.edit('input_txt');
    editor.setTheme('ace/theme/dracula');
    editor.focus();
    editor.gotoLine(1);
    editor.renderer.setShowPrintMargin(false);

    setEditorTheme();

    document.addEventListener('dragover', (event) => {
        event.preventDefault();
    });
    document.addEventListener('drop', (event) => {
        event.preventDefault();
    });

    inputArea.addEventListener('dragover', (event) => {
        event.preventDefault();
    });
    inputArea.addEventListener('dragleave', (event) => {
        event.preventDefault();
    });
    inputArea.addEventListener('dragend', (event) => {
        event.preventDefault();
    });
    inputArea.addEventListener('drop', (event) => {
        event.preventDefault();
        const file = event.dataTransfer.files[0];
        readFile(file.path);
    });

    document.querySelector('#btnLoad').addEventListener('click', () => {
        openLoadFile();
    });
    document.querySelector('#btnSave').addEventListener('click', () => {
        saveFile();
    });
};

function openLoadFile() {
    const win = BrowserWindow.getFocusedWindow();

    dialog.showOpenDialog(
        win,
        {
            properties: ['openFile'],
            filters: [
                {
                    name: 'Documents',
                    extensions: ['*']
                }
            ]
        },
        (fileNames) => {
            if (fileNames) {
                readFile(fileNames[0]);
            }
        });
}

function readFile(path) {
    currentPath = path;
    fs.readFile(path, (error, text) => {
        if (error != null) {
            alert('error : ' + error);
            return;
        }
        footerArea.innerHTML = path;
        editor.setValue(text.toString(), -1);
        setEditorTheme(path);
    });
}

function saveFile() {
    if (currentPath === '') {
        saveNewFile();
        return;
    }

    const win = BrowserWindow.getFocusedWindow();

    dialog.showMessageBox(win, {
        title: 'ファイルの上書き保存を行います。',
        type: 'info',
        buttons: ['OK', 'Cancel'],
        detail: '本当に保存しますか？'
    },
        (response) => {
            if (response === 0) {
                const data = editor.getValue();
                writeFile(currentPath, data);
            }
        }
    );
}

function writeFile(path, data) {
    fs.writeFile(path, data, (error) => {
        if (error != null) {
            alert('error : ' + error);
        } else {
            setEditorTheme(path);
        }
    });
}

function saveNewFile() {
    const win = BrowserWindow.getFocusedWindow();
    dialog.showSaveDialog(
        win,
        {
            properties: ['openFile'],
            filters: [
                {
                    name: 'Documents',
                    extensions: ['*']
                }
            ]
        },
        (fileName) => {
            if (fileName) {
                const data = editor.getValue();
                currentPath = fileName;
                writeFile(currentPath, data);
            }
        }
    );
}

function setEditorTheme(fileName = '') {
    const type = fileName.split('.');
    const ext = type[type.length - 1].toLowerCase()
    switch (ext) {
        case 'txt':
            editor.getSession().setMode('ace/mode/plain_text');
            break;
        case 'py':
            editor.getSession().setMode('ace/mode/python');
            break;
        case 'rb':
            editor.getSession().setMode('ace/mode/ruby');
            break;
        case 'c':
        case 'cpp':
        case 'h':
            editor.getSession().setMode('ace/mode/c_cpp');
            break
        case 'html':
            editor.getSession().setMode('ace/mode/html');
            break;
        case 'js':
            editor.getSession().setMode('ace/mode/javascript');
            break;
        case 'md':
            editor.getSession().setMode('ace/mode/markdown');
            break;
        default:
            editor.getSession().setMode('ace/mode/plain_text');
            break;
    }
}
```

詳細は上記で紹介した記事を参考にしてください．

### main.css

`position: fixed;`を使用してヘッダとフッタの位置を決めて，その他の領域にテキスト入力エリア全体に配置しています．

```css:main.css
* {
    margin: 0px;
    padding: 0px;
}

html,
body {
    width: 100%;
    height: 100%;
    background-color: #282a36;
    overflow: hidden;
}

/** ヘッダーが34px、フッターが20px */

#input_area {
    padding: 34px 0px 20px 0px;
    height: 100%;
}

#input_txt {
    width: 100%;
    height: 100%;
}

#header_fixed {
    position: fixed;
    height: 34px;
    width: 100%;
}

#footer_fixed {
    position: fixed;
    height: 20px;
    width: 100%;
    bottom: 0px;
    background-color: #40587b;
    color: #eeeeee;
    font-size: 80%;
}
```

## 完成

ここまでで，冒頭で目標としていたテキストエディタがひととおり完成しました:clap:

# パッケージング

最後にElectronアプリをパッケージングする方法についてまとめます．せっかく作ったアプリなのでまわりに配布しやすいように実行ファイル形式にパッケージングすると便利です．

## electron-packagerのインストールと実行

Electronアプリのパッケージングには`electron-packager`を使用します．まずはnpmからインストールしましょう．

```console
> npm i -D electron-packager
```

electron-packagerのインストールができたら実行しましょう．下記はWindows向けアプリケーションを作るときのオプションです．`--icon`でアイコンを設定することもできます．お気に入りのアプリを作った際にはぜひアイコンもつけましょう．

<img width="175" alt="image.png" src="https://qiita-image-store.s3.amazonaws.com/0/45310/ae608c79-7ee8-f1dd-d581-87c5eba239a0.png">

```console
> npx electron-packager src my_electron --platform=win32 --arch=x64 --overwrite --icon=src/icon.ico
```

ついでにタイトルバーに表示するアイコンも`main.js`で指定できます．

```javascript:main.js
mainWindow = new BrowserWindow({ width: 800, height: 600, 'icon': __dirname + '/icon.ico', });
```

## npm scripts

最後にこのパッケージング用のコマンドを毎回入力するのは面倒なのでnpm scriptsを利用すると便利です．ルートのほうの`package.json`ファイルの`scripts`にコマンドを登録しておきます．

```json:package.json
{
  :
  "scripts": {
    "start": "electron ./src",
    "build-macOS": "electron-packager ./src my_electron --platform=darwin --arch=x64 --overwrite --icon=src/icon.ico",
    "build-windows": "electron-packager ./src my_electron --platform=win32 --arch=x64 --overwrite --icon=src/icon.ico"
  },
  :
}
```

package.jsonに登録しておくと，以下のようなコマンドで簡単にパッケージングを行うことができます．

```console
> npm run build-windows
```

# Electronのバージョン5以降の対応

上記までの内容はElectronの初期のころのバージョンで試したものでしたが，バージョン5以降では`nodeIntegration`がデフォルトで`false`となっているため，editor.jsの2行目で下記のようなエラーが出てしまいます．
※コメントで教えていただきました:bow_tone1:

```
Uncaught TypeError: Cannot read property 'remote' of undefined
```

対策として`preload`を使うとよさそうです．

```javascript:main.js
mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
        preload: `${__dirname}/preload.js`,    // preloadを追加
        enableRemoteModule: true               // warning対策
    },
    'icon': __dirname + '/shimarin.ico'
});
```

preloadで指定している`preload.js`ファイル次の内容で新規に作成します．

```javascript:preload.js
window.ipcRenderer = require('electron').ipcRenderer;
window.remote = require('electron').remote;
```

最後に`editor.js`の冒頭の内容を次のように書き換えます．

```javascript:editor.js
const fs = remote.require('fs');
const { BrowserWindow, dialog } = remote;
```

ついでに，自分がこれを試したElectronのバージョン（9.0.0）ではdialogまわりも動かなくなっていました．次のように`openLoadFile()`，`saveFile()`，`saveNewFile()`を修正するといいと思います．

```javascript:editor.js
function openLoadFile() {
    const win = BrowserWindow.getFocusedWindow();
    dialog.showOpenDialog(win, {
        properties: ['openFile'],
        filters: [
            {
                name: 'Documents',
                extensions: ['*']
            }
        ]
    }).then(result => {
        if (!result.canceled) {
            readFile(result.filePaths[0]);
        }
    }).catch(err => {
        console.log(err)
    });
}

function saveFile() {
    if (currentPath === '') {
        saveNewFile();
        return;
    }

    const win = BrowserWindow.getFocusedWindow();
    dialog.showMessageBox(win, {
        title: 'ファイルの上書き保存を行います。',
        type: 'info',
        buttons: ['OK', 'Cancel'],
        detail: '本当に保存しますか？'
    }).then(result => {
        if (result.response === 0) {
            const data = editor.getValue();
            writeFile(currentPath, data);
        }
    }).catch(err => {
        console.log(err)
    });
}

function saveNewFile() {
    const win = BrowserWindow.getFocusedWindow();
    dialog.showSaveDialog(win, {
        properties: ['openFile'],
        filters: [
            {
                name: 'Documents',
                extensions: ['*']
            }
        ]
    }).then(result => {
        if (!result.canceled) {
            const data = editor.getValue();
            currentPath = result.filePath;
            writeFile(currentPath, data);
        }
    }).catch(err => {
        console.log(err)
    });
}
```

# おわりに

無事にElectronアプリをひとつ作り上げることができました:tada:
ここまでやれるとアイデア次第でいろいろと拡張していけると思うのでぜひチャレンジしていきたいです．