Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
166
Help us understand the problem. What is going on with this article?
@y-tsutsu

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

はじめに

ここ1~2年ほど,自分はElectron製のアプリにかなりお世話になっています.こちらでも紹介したのですが,特にVisual Studio Codeと出会えなかったら今ほど楽しくコードが書けていないかもしれません.これほどお世話になっている技術なので,自分でもElectronでアプリを作ることに挑戦してみようと思います.

実際に作ったアプリ

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

image.png

Electronとは?

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

開発環境

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

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

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

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

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

はじめはHello, World!

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

package.jsonの作成

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

> mkdir my_electron
> cd my_electron
> npm init -y
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以下にインストールされます

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

Welcomeウィンドウの表示

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

> npx electron

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

image.png

自作ウィンドウの表示

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

  • index.html
  • main.js
  • package.json

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

image.png

index.html

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

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を指定している個所もあります.

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を指定しています.

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

Hello, World!

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

> npx electron src

image.png

テキストエディタの作成

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

Ace.js

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

コードの更新と追加

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

  • index.html(更新)
  • editor.js(新規)
  • main.css(新規)

image.png

index.html

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

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で設定できます.ファイルの読み込み時などに拡張子からターゲットの言語を変更しています.

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;を使用してヘッダとフッタの位置を決めて,その他の領域にテキスト入力エリア全体に配置しています.

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からインストールしましょう.

> npm i -D electron-packager

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

image.png

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

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

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

npm scripts

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

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に登録しておくと,以下のようなコマンドで簡単にパッケージングを行うことができます.

> npm run build-windows

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

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

Uncaught TypeError: Cannot read property 'remote' of undefined

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

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ファイル次の内容で新規に作成します.

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

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

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

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

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:
ここまでやれるとアイデア次第でいろいろと拡張していけると思うのでぜひチャレンジしていきたいです.

166
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
y-tsutsu
itage
ITAGEは「IT」のAGENCYになることを夢、目標として進化、変化していきます。「It’s It Agency」

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
166
Help us understand the problem. What is going on with this article?