はじめに
Electronをご存知でしょうか.
Electron(旧称: Atom-Shell)は, Atomエディタを開発するために生まれたクロスプラットフォームデスクトップアプリケーションエンジンです.
Node.js + Chromiumをランタイムとしており, Atomだけでなく, Slackや先日のBuildで発表されて話題となったVisualStudio CodeもElectronで実装されています.
いわゆるWeb系の技術, Node.js + HTML + CSSでアプリケーションを作成できるのが特徴です.
類似した思想のフレームワークとして, node-webkit(NW.js)もありますが, アプリケーションのエントリポイントの考え方等が異なります.
このエントリでは, Electronの使い方をサンプルアプリを実装しながら説明していきます.
お題
このエントリのお題として, 下記の機能を持つアプリケーション「ReadUs」を作ってみることにします.
- 適当なディレクトリ配下に存在する
node_modules
ディレクトリについて, 中に含まれるREADME.mdの一覧を作成. - ユーザが一覧から選択したREADME.mdについてはプレビューで表示.
僕も普段, Node.jsでの開発を行っていますが, Node.jsのライブラリって一つ一つの粒度が結構細かいため, 機能の使い方をしりたくなったときにリンク集的に使えるようなアプリがあればなぁ、と思ったのがこのお題を選んだきっかけです.
準備
まずはElectronをインストールしましょう(Node.jsは既にインストール済みであるとします).
以下のコマンドでElectronがインストールされます.
npm -g install electron
次にアプリケーションとなるProjectを作成します.
mkdir electron-readus; cd electron-readus
npm init -y
Electronでは, package.jsonのmain
に記載されたJavaScriptファイルがアプリケーションのエントリポイントとなります.
ここではmain.js
とします.
{
...
"main": "main.js",
...
}
main.jsを作成して, 下記のように編集します.
const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
let mainWindow = null;
app.on('window-all-closed', function() {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('ready', function() {
// ブラウザ(Chromium)の起動, 初期画面のロード
mainWindow = new BrowserWindow({width: 800, height: 600});
mainWindow.loadURL('file://' + __dirname + '/index.html');
mainWindow.on('closed', function() {
mainWindow = null;
});
});
続いて画面となるindex.htmlを作りましょう.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Electron Read Us</title>
</head>
<body>
<h1>Hello, electron!</h1>
</body>
</html>
この段階で, プロジェクトのディレクトリは下記のようになっているはずです.
index.html
main.js
package.json
Electronを実行できるようになっています. 下記のコマンドでアプリを立ち上げてみます.
electron .
下記のような画面が開きましたか?
アプリっぽく
さて, ここからはアプリケーションの機能を実装していきます.
今回のアプリは node_modules
配下に存在するREADME.mdの一覧が必要です.
何せファイル一覧を作成しないことには始まりません.
対象となるREADMEのファイルパスは, node_modules/**/README.md
とGlobのパターンで簡単に記載できるので, node-globを使います.
npm install glob --save
lib/fileUtil.js
の名前でファイルを新規作成し, 下記のようにします.
const glob = require('glob');
const fileUtil = {
fetchReadmeList: function (cb) {
glob('node_modules/**/README.md', function (err, matches) {
if(err) {
cb(err, null);
return;
}
cb(null, matches);
});
}
};
module.exports = fileUtil;
ただglobを呼び出して, コールバックに結果を渡すだけのシンプルなメソッドです.
動作確認のために, このfileUtil.fetchReadmeList
を画面から呼び出してみます.
次の内容でindex.jsを作成し, index.htmlに <script src="index.js"></script>
を追記しましょう.
'use strict';
const electron = require('electron');
const remote = electron.remote;
const fileUtil = remote.require('./lib/fileUtil');
fileUtil.fetchReadmeList(function(err, matches) {
if(!err) document.write(matches.join());
});
<body>
...
<script src="index.js"></script>
</body>
さて, electron .
で起動してみましょう.
現在, この electron-readusプロジェクトには node-glob
がinstallされている筈なので, 10個程度のREADME.mdが引っ掛かり, 画面上に列挙される筈です.
remoteオブジェクトについて
ここでremoteオブジェクトについて少し触れておきます.
index.jsからfileUtilを利用する際, remote.require('...')
としている点に注意してください.
この部分は, 通常のNode.jsにおけるモジュール読み込みのように
const fileUtil = require('./lib/fileUtil');
とすることも可能です. しかし, remote
の利用有無で以下の違いがあります.
-
remote
を経由した場合: BrowserProcess上でrequireされたオブジェクトが渡される(fileUtilはBrowserProcess上で動作する) -
remote
を使わずに直接requireした場合: fileUtilはRendererProcess上で動作する.
いきなりBrowserProcessとかRendererProcessとか書いてしまいましたが, Electronはマルチプロセスで動作します.
Electronのプロセスには2種類あり, main.jsが動いているProcess(=BrowserProcess. Main Processとも)と, 画面のレンダリングを担うProcess(=RendererProcess)の二種類が存在します.
RendererProcessで画面描画とあまり関係ないロジックを動作させるのは推奨されないため, 重たい処理はBrowserProcess側で動作させるようにした方がよいです.
画面を作っていく
ここからは画面を作っていきます.
今回はAngularJS(MVVMフレームワーク) + Marked(MarkDown変換ライブラリ)を使って, README.mdをプレビュー表示します.
Angular, MarkedはBowerで簡単にインストールできます. 以下のコマンドを実行します:
npm -g install bower
bower init (質問は適当に)
bower install angular marked --save
このエントリでは, AngularJSの細かい使い方について述べたい訳ではないので, 詳細は省略します.
もし, AngularJSにあまり明るくなければ「ふーん、そういうもんなんだ」程度で流して下さい.
htmlは下記のようにします. <ul>
にREADMEの一覧, <div md-preview>
にMarkdownのプレビューを持つだけのシンプルな構成です.
<!DOCTYPE html>
<html ng-app="readUs">
<head>
<meta charset="UTF-8">
<title>Electron Read Us</title>
</head>
<body ng-controller="MainController as main">
<!-- READMEの一覧 -->
<ul>
<li ng-repeat="file in main.fileList">
<a ng-click="main.getFile(file)">{{file.moduleName}}</a>
</li>
</ul>
<!-- 選択したREADMEをMarkdown表示 -->
<div ng-if="main.fileText">
<div md-preview="main.fileText"></div>
</div>
<script src="bower_components/angular/angular.js"></script>
<script src="bower_components/marked/lib/marked.js"></script>
<script src="index.js"></script>
</body>
</html>
また, index.jsは下記のようになります. 先述したように, remote経由でfileUtilにアクセスし, 一覧やファイルの中身を取得するようにしています.
'use strict';
var electron = require('electron');
var remote = electron.remote;
var fileUtil = remote.require('./lib/fileUtil');
var baseDir = process.cwd();
var ngModule = angular.module('readUs', []);
ngModule.controller('MainController', function ($scope) {
var main = this;
// README.mdの取得
main.getFile = function(file) {
main.fileText = fileUtil.getAsText(file.filepath);
};
fileUtil.fetchReadmeList(baseDir, function (err, fileList) {
if(err) console.error(err);
$scope.$apply(function () {
main.fileList = fileList;
});
});
});
ngModule.directive('mdPreview', function () {
return function ($scope, $elem, $attrs) {
$scope.$watch($attrs.mdPreview, function(source) {
$elem.html(marked(source));
});
};
});
併せてfileUtil.jsも少し修正しています.
'use strict';
var glob = require('glob');
var path = require('path');
var fs = require('fs');
var fileUtil = {
fetchReadmeList: function (baseDir, cb) {
glob('node_modules/**/README.md', {cwd: baseDir}, function (err, matches) {
if(err) {
cb(err, null);
return;
}
cb(null, matches.map(function (filename){
var split = path.dirname(filename).split(path.sep), modNames = [];
for (var i = split.length - 1; i >= 0; i--) {
if(split[i] === 'node_modules') break;
modNames.push(split[i]);
}
return {
filepath: path.join(baseDir, filename),
moduleName: modNames.join('/')
};
}));
});
},
getAsText: function (filename) {
return fs.readFileSync(filename, 'utf-8');
}
};
module.exports = fileUtil;
起動してみましょう. 表示されるリンクを適当にクリックすると, 下記のようにREADMEが表示されるはずです.
Menuを追加する
ここまで作ったReadUsをよりデスクトップアプリケーションらしくするために, メニューを追加してみます.
ユーザがREADMEの探索元となるディレクトリを指定できるようにしてみます.
既にfileUtilには, 探索起点ディレクトリであるbaseDirを引数として指定できるようにしてあるため, あとはUI部分を作るだけです.
Electronには, アプリケーションメニューとコンテキストメニューの2種類があります.
アプリケーションメニューはメニューバーに表示されるメニュー, コンテキストメニューは画面を右クリックした際に表示されるメニューです.
今回はアプリケーションメニューを追加します. アプリケーションメニューはBrowserProcess側で追加を行うため, main.jsを下記のようにします.
'use strict';
const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
const Menu = electron.Menu;
const {crashReporter, dialog} = require('electron');
crashReporter.start({
productName: 'YourName',
companyName: 'YourCompany',
submitURL: 'https://your-domain.com/url-to-submit',
autoSubmit: true
});
app.on('window-all-closed', function() {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('ready', function() {
// メニューをアプリケーションに追加
Menu.setApplicationMenu(menu);
openWindow(process.cwd());
});
function openWindow (baseDir) {
let win = new BrowserWindow({width: 800, height: 600});
win.loadURL('file://' + __dirname + '/index.html?baseDir=' + encodeURIComponent(baseDir));
win.on('closed', function () {
win = null;
});
}
// メニュー情報の作成
const template = [
{
label: 'ReadUs',
submenu: [
{label: 'Quit',
accelerator: 'Command+Q',
click: function () {
app.quit();
}}
]
}, {
label: 'File',
submenu: [
{
label: 'Open',
accelerator: 'Command+O',
click: function () {
// 「ファイルを開く」ダイアログの呼び出し
dialog.showOpenDialog({properties: ['openDirectory']}, function (baseDir) {
if (baseDir && baseDir[0]) {
openWindow(baseDir[0]);
}
})
}
}
]
}, {
label: 'View',
submenu: [
{label: 'Reload',
accelerator: 'Command+R',
click: function () {
BrowserWindow.getFocusedWindow().reload();
}},
{
label: 'Toggle DevTools',
accelerator: 'Alt+Command+I',
click: function () {
BrowserWindow.getFocusedWindow().toggleDevTools();
}
}
]
}
];
const menu = Menu.buildFromTemplate(template);
MenuモジュールのbuildFromTemplate
関数にtemplate
を与え, アプリケーションの起動時(app.on('ready', ...)
)にて, setApplicationMenu
を実行することでアプリケーションメニューを追加しています.
メニューの元となっているtemplate
について, accelerator
でショートカットキーの指定, click
にクリックされた際に動作するハンドラ関数を設定可能です.
ユーザにディレクトリを開かせるOpenメニューを含め, 追加したメニューは以下の4つです.
- ReadUs
- Quit: アプリケーションを終了させる.
- File
- Open: 探索の起点となるディレクトリを選択させ, 新規にRendererProcessを立ち上げる.
- View
- Reload: RendererProcessを再描画させる.
- Toggle Devtools: Chromiumの開発者ツールの表示を切り替える.
Openメニューでは, Electronのdialogモジュールを使っています. dialogモジュールのshowOpenDialog
関数は「ファイルを開く」ダイアログを表示することができます.
今回はディレクトリを開かせたいので, openDirectory
プロパティをセットしました.
dialogモジュールにはshowOpenDialog
以外にも数種類のダイアログを表示するための機能が備わっています(詳細)
ViewメニューのReload, Toggle Devtoolsの2つは, エンドユーザにとっては無くても構わないのですが, 開発時に便利な機能です. 両方ともBrowserWindow自体に備わっている機能を利用しています.
さて, Openメニューで選択したディレクトリ情報は, RendererProcess側にクエリストリングとして渡しています.
先述のindex.jsにてvar baseDir = process.cwd()
としていた箇所を下記で置き換えれば, ディレクトリパスの受け渡し処理が完成します.
var matched = location.search.match(/baseDir=([^&]*)/);
var baseDir = matched && decodeURIComponent(matched[1]);
配布してみる.
まだアプリには色々改善の余地がありそうですが, 一旦それはさておいて, 作成したアプリを配布することを考えてみます.
Electronのアプリケーションはasarというツールでアーカイビングできます. まずはasarをインストールします.
$ npm install -g asar
続いて, アプリケーションのディレクトリでasarのpackコマンドを実行します.
asar pack . ~/app.asar
asarコマンドは第2引数に作成するアーカイブのファイル名を指定しますが, アプリケーションのディレクトリ外を指定するようにしましょう.
また, 作成するファイル名はapp.asarとしておきます.
作成したapp.asarはelectron ~/app.asar
のようにして, electronコマンドで実行可能です.
さて, Electronをインストール済みのユーザにはapp.asarを直接配布しても構わないのですが, 通常アプリケーションを渡したい相手が既に Electronを導入済みであるとは考えにくいですので, Electronの実行環境も含めて渡すようにします.
ここから, ElectronのディストリビューションをDLし一旦解凍します.
先ほど作成したapp.asarを下記のディレクトリにコピーします.
- OS Xの場合:
(解凍してできたディレクトリ)/Electron.app/Contents/Resources/
- Windows or Linuxの場合:
(解凍してできたディレクトリ)/resources/
これでElectron.appやElectron.exeをダブルクリックして起動できるようになっているはずです.
Electron自体は, exe名称やアイコンを変更する機能を持ち合わせていませんが, プラットフォーム毎にこれらを変更する方法がガイドに記載されています.
まとめ
Electronを用いたデスクトップアプリケーションの基本を書いてきましたが, 如何でしたか?
僕の所感になりますが, Web開発者にとって, 日頃使い慣れている画面系のフレームワークを載せたり, Node.jsのライブラリが利用できるので, アイディア次第で色々作れそうです.
また, ドキュメントがかなり綺麗に整備されているお陰で, サクサク開発が行えました.
Electronで次世代キ○タマを作った にもう少しアプリケーションらしい(?)話を上げています
ぼくのかんがえたさいきょうのElectron にElectronの開発環境関連の話を上げています