はじめに
ここ1~2年ほど,自分はElectron製のアプリにかなりお世話になっています.こちらでも紹介したのですが,特にVisual Studio Codeと出会えなかったら今ほど楽しくコードが書けていないかもしれません.これほどお世話になっている技術なので,自分でもElectronでアプリを作ることに挑戦してみようと思います.
実際に作ったアプリ
このような感じのテキストエディタを作りました.このゴールに向けて順にまとめていきます.
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
{
"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
何やらかっこいいウィンドウが表示されました.
自作ウィンドウの表示
かっこいいのはいいですが,さすがに何もやっていないので自前でウィンドウを作成して表示させます.
今回はsrc
ディレクトリを作成し,その中に以下の3ファイルを用意します.
- index.html
- main.js
- package.json
ファイルの構成はこのようになっています.
index.html
表示させるウィンドウのHTMLです.とりあえず何でもいいので簡単に用意します.試しにBootstrapも使ってみました.
<!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
を指定している個所もあります.
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
を指定しています.
{
"main": "main.js"
}
Hello, World!
ここまでコードを用意できたら以下のコマンドでウィンドウが表示されます.Hello, World!を表示させているだけなので分かりづらいですが,Bootstrapもきちんと適用されているようです.(フォントが変わっています.)
> npx electron src
テキストエディタの作成
ウィンドウの表示までできたので,次は目標に向けて簡単なアプリの作成を行います.
ここからはこちらの記事にお世話になりました.感謝です
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(新規)
index.html
テキストエディタとしての画面を作成します.ヘッダとフッタ,テキスト入力エリアのシンプルな構成です.またWebフォントなんかも通常のWebアプリと同じように使用することができます.
<!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
で設定できます.ファイルの読み込み時などに拡張子からターゲットの言語を変更しています.
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;
を使用してヘッダとフッタの位置を決めて,その他の領域にテキスト入力エリア全体に配置しています.
* {
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%;
}
完成
ここまでで,冒頭で目標としていたテキストエディタがひととおり完成しました
パッケージング
最後にElectronアプリをパッケージングする方法についてまとめます.せっかく作ったアプリなのでまわりに配布しやすいように実行ファイル形式にパッケージングすると便利です.
electron-packagerのインストールと実行
Electronアプリのパッケージングにはelectron-packager
を使用します.まずはnpmからインストールしましょう.
> npm i -D electron-packager
electron-packagerのインストールができたら実行しましょう.下記はWindows向けアプリケーションを作るときのオプションです.--icon
でアイコンを設定することもできます.お気に入りのアプリを作った際にはぜひアイコンもつけましょう.
> npx electron-packager src my_electron --platform=win32 --arch=x64 --overwrite --icon=src/icon.ico
ついでにタイトルバーに表示するアイコンもmain.js
で指定できます.
mainWindow = new BrowserWindow({ width: 800, height: 600, 'icon': __dirname + '/icon.ico', });
npm scripts
最後にこのパッケージング用のコマンドを毎回入力するのは面倒なのでnpm scriptsを利用すると便利です.ルートのほうのpackage.json
ファイルのscripts
にコマンドを登録しておきます.
{
:
"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行目で下記のようなエラーが出てしまいます.
※コメントで教えていただきました
Uncaught TypeError: Cannot read property 'remote' of undefined
対策としてpreload
を使うとよさそうです.
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: `${__dirname}/preload.js`, // preloadを追加
enableRemoteModule: true // warning対策
},
'icon': __dirname + '/shimarin.ico'
});
preloadで指定しているpreload.js
ファイル次の内容で新規に作成します.
window.ipcRenderer = require('electron').ipcRenderer;
window.remote = require('electron').remote;
最後にeditor.js
の冒頭の内容を次のように書き換えます.
const fs = remote.require('fs');
const { BrowserWindow, dialog } = remote;
ついでに,自分がこれを試したElectronのバージョン(9.0.0)ではdialogまわりも動かなくなっていました.次のようにopenLoadFile()
,saveFile()
,saveNewFile()
を修正するといいと思います.
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アプリをひとつ作り上げることができました
ここまでやれるとアイデア次第でいろいろと拡張していけると思うのでぜひチャレンジしていきたいです.