Electron を試す – 開発環境の構築

2015年9月29日 0 開発 , , , ,

これまで NW.js を使ってきたが、同じ Chromium + Node 系のフレームワークとして最近は Electron のほうが勢いあるようなので試したくなった。

使用感を把握するため、まずは開発環境を構築してみる。

更新履歴

  • 2015/11/5
    npm-scripts を babelify 7.2 ( Babel 6.x ) を採用した内容へ更新。また最新 watchify の Windows 対応について追記した。これらの詳細については babelify v7.2 を試すを参照のこと。
    2015/10/19
    npm-scripts を最新へ更新し、Main プロセスのビルド説明に Browserify の –node オプション解説を追加。

もくじ

設計方針

  1. package.json と npm だけを使用
  2. AltCSS は Stylus を採用
  3. ユニット テスト対応
  4. コード ドキュメント対応
  5. Windows 環境を考慮する
  6. Main/Renderer プロセスを両方とも ES6 で実装
  7. Main/Renderer プロセスを両方とも Browserify/babelify で bundle

方針 1 〜 5 までは gulp なしの Web フロントエンド開発 の内容を踏襲する。よって npm-scripts などは掲載するが詳細な解説は割愛。

重要なのは 6 と 7。Electron を試してみた系で ES6 を扱う記事だと babel を利用するのが一般的なようだ。Electron 環境なら require を利用できるため、ES5 コンパイルだけで十分なのだろう。

しかし node_modules があると、パッケージに組み込むのが厄介そうだし、不要なファイルもついてくるので Browserify で bundle することにした。

プロジェクト構成

開発環境の構築にあたり、Electron 用の Starter Kit プロジェクトを作成してみた。ファイルとディレクトリ構成は以下。

/
├ dist/
│ ├ bin/
│ ├ cache/
│ ├ src/
├ esdoc/
├ node_modules/
├ res/
├ src/
│ ├ fonts/
│ ├ js/
│ │ ├ common/
│ │ ├ main/
│ │ └ renderer/
│ ├ stylus/
│ ├ index.html
│ └ package.json
├ test/
├ esdoc.json
├ LICENSE
├ package.json
└ README.md

各種ファイル、ディレクトリの詳細を表にまとめる。dist、esdoc、node_modules は自動生成されるディレクトリ。

名前 内容
dist/ リリース用イメージ置き場。
dist/bin/ パッケージ化された各プラットフォームのアプリ置き場。
dist/cache/ パッケージ処理に使用する各プラットフォームの Electron イメージ置き場。
dist/src/ パッケージの元となるリソース置き場。
esdoc/ ESDoc により生成されたコード ドキュメント置き場。
node_modules/ Node モジュール置き場。
res/ アプリのアイコンなどパッケージ用リソース置き場。
src/ 開発用リソース置き場。
src/fonts/ アイコン フォント置き場。
src/js/common/ Main/Renderer プロセス共通のコード置き場。
src/js/main/ Main プロセス用コード置き場。
src/js/renderer/ Renderer プロセス用コード置き場。
src/stylus/ Stylus ファイル置き場。
src/index.html Renderer プロセスのエントリー ポイントになる HTML ファイル。
src/package.json リリース用 package.json。
test/ ユニット テスト用コード置き場。
esdoc.json ESDoc 設定ファイル。
LICENSE ライセンス ファイル。
package.json 開発用 package.json。
README.md プロジェクトの説明ファイル。

package.json 管理

今回のプロジェクトでは開発とリリースで package.json を分けている。以下は開発用の全体像。

{
  "name": "electron-audio-player",
  "description": "Example of simple audio player in Electron.",
  "version": "1.0.2",
  "author": "akabeko",
  "license": "MIT",
  "main": "main.js",
  "keywords": [
    "Electron",
    "Audio",
    "Player"
  ],
  "repository": {
    "type": "git",
    "url": "git+https://github.com/akabekobeko/examples-electron.git"
  },
  "scripts": {
    "start": "npm run watch",
    "app": "electron --debug=5858 src/",
    "test": "mocha --compilers js:espower-babel/guess test/**/*.test.js",
    "esdoc": "esdoc -c esdoc.json",
    "build:css": "stylus -c ./src/stylus/App.styl -o ./src/bundle.css -m --sourcemap-root ./stylus",
    "build:js-main": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js",
    "build:js-renderer": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js -d | exorcist ./src/bundle.js.map > ./src/bundle.js",
    "build": "npm-run-all -p build:css build:js-main build:js-renderer",
    "watch:css": "stylus -c -w ./src/stylus/App.styl -o ./src/bundle.css -m --sourcemap-root ./stylus",
    "watch:js-main": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -o \"exorcist ./src/main.js.map > ./src/main.js\" -d",
    "watch:js-renderer": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
    "watch:server": "node-inspector",
    "watch": "npm-run-all -p watch:js-main watch:js-renderer watch:css watch:server",
    "release:css": "stylus -c ./src/stylus/App.styl -o ./dist/src/bundle.css",
    "release:js-main": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/main.js",
    "release:js-renderer": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/bundle.js",
    "release:clean": "rimraf ./dist/src",
    "release:copy": "cpx \"./src/**/{*.html,*.eot,*.svg,*.ttf,*.woff,package.json}\" ./dist/src",
    "release:build": "npm-run-all -s release:clean release:copy -p release:css release:js-main release:js-renderer",
    "release:pack-osx": "electron-packager ./dist/src AudioPlayer --out=dist/bin --cache=dist/cache --platform=darwin --arch=x64 --version=0.34.2 --overwrite --asar --icon=res/app.icns",
    "release:pack-win": "electron-packager ./dist/src AudioPlayer --out=dist/bin --cache=dist/cache --platform=win32 --arch=x64 --version=0.34.2 --overwrite --asar --icon=res/app.ico --version-string.CompanyName=\"Company\" --version-string.LegalCopyright=\"Copylight (C) USERNAME, All right reserved.\" --version-string.FileDescription=\"Electron application\" --version-string.OriginalFilename=\"AudioPlayer.exe\" --version-string.FileVersion=\"1.0.2\" --version-string.ProductVersion=\"1.0.2\" --version-string.ProductName=\"AudioPlayer\" --version-string.InternalName=\"AudioPlayer\"",
    "release:pack-linux": "electron-packager ./dist/src AudioPlayer --out=dist/bin --cache=dist/cache --platform=linux --arch=x64 --version=0.34.2 --overwrite --asar",
    "release:osx": "npm-run-all -s release:build release:pack-osx",
    "release:win": "npm-run-all -s release:build release:pack-win",
    "release:linux": "npm-run-all -s release:build release:pack-linux",
    "release": "npm-run-all -s release:build release:pack-osx release:pack-win release:pack-linux"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.0.15",
    "babel-preset-react": "^6.0.15",
    "babelify": "^7.2.0",
    "browserify": "^12.0.1",
    "cpx": "^1.2.1",
    "electron-packager": "^5.1.1",
    "electron-prebuilt": "^0.34.2",
    "esdoc": "^0.4.3",
    "espower-babel": "^3.3.0",
    "exorcist": "^0.4.0",
    "mocha": "^2.3.3",
    "ncp": "^2.0.0",
    "node-inspector": "^0.12.3",
    "npm-run-all": "^1.2.12",
    "power-assert": "^1.1.0",
    "rimraf": "^2.4.3",
    "stylus": "^0.52.4",
    "uglify-js": "^2.5.0",
    "watchify": "^3.6.0"
  },
  "dependencies": {
    "material-flux": "^1.2.3",
    "musicmetadata": "^2.0.2",
    "react": "^0.14.2",
    "react-dom": "^0.14.2",
    "react-split-pane": "^0.1.17"
  }
}

リリース用 package.json

開発用 package.json から repository 以降を削除したものを src/ 直下にリリース用として配置している。

ファイルを分ける理由は、リリース用イメージに scripts や dependencies などの開発情報を含めたくないから。

商用アプリで Electron を採用した場合、顧客要望として開発情報の削除は十分にありえる。これを実現する場合、ひとつの package.json を動的に加工するより静的に配置しておいたほうが楽である。また Main/Renderer プロセスの両方を Browserify で bundle しているため、リリース用イメージには dependencies と node_modules が不要である。

この方針は NW.js の影響も受けている。NW.js ではウィンドウなどのアプリ設定自体が package.json に記述されるため開発とリリース用ファイルの分割は必然なのだが、すぐに慣れた。

package.json が分かれていても、name 〜 keywords までなら変更はそれほど発生しないはず。リリース頻度にもよるが version 情報の更新だけは面倒かもしれない。

Main プロセスのビルド

NW.js と異なり、Electron ではプロセスが Main/Renderer に分かれている。Main プロセスは Electron アプリ全体のエントリー ポイントになる。プラットフォーム固有の処理、例えばウィンドウやメニューなどの処理を担当する。

Main プロセスからウィンドウを生成し、その中に読み込まれた HTML 上の JavaScript が Renderer プロセスになる。この仕組みにより、単体の Main プロセスから複数のウィンドウ ( Renderer プロセス ) を生成したり、それらを横断的に管理することも可能。

Client-Server モデルで表すなら Server になるだろうか。

設定

Main プロセスのビルド設定を以下のように定義してみた。

{
  "scripts": {
    "build:js-main": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -d | exorcist ./src/main.js.map > ./src/main.js",
    "watch:js-main": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node -o \"exorcist ./src/main.js.map > ./src/main.js\" -d",
    "release:js-main": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/main/Main.js --im --no-detect-globals --node | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/main.js"
}

build:js-main は Main プロセス単体のビルドを実行する。

Main プロセスは electron/debugging-main-process.md に説明されているように node-inspactor と Chrome を組み合わせたデバッガを利用できる。その際 Source Maps も有効なので bundle ついでに生成している。

watch:js-main は watchify によるファイル監視で Main プロセスを差分ビルドする。しかし Main プロセスを実行しながら再読み込みする方法が不明なので、ビルドは自動化しつつ変更があったときはアプリを再起動している。

{
  "scripts": {
    "watch:js-main": "watchify -v -t babelify ./src/js/main/Main.js  --im --no-detect-globals --node -o ./src/main.js"
  }
}

release:js-main はリリース用ビルドを実行する。ファイルの生成先を dist/ ではなく dist/src にしている理由は dist/ 配下へ他にも Electron ビルド用のディレクトリが置かれるため。

Browserify の –im オプション

--im または --ignore-missing オプションを指定すると、import/require の厳密なチェックが無効化される。つまり import/require 対象が package.json の dependencies などに定義されていれば bundle するし、なければ無視する。

このオプションを有効にしないと Electron 由来の crash-reporter や browser-window の import でエラーになるため、これを避けるために指定している。

Browserify の –no-detect-globals オプション

--no-detect-globals オプションを指定すると require や __dirname などのグローバル定義の変更が無効化される。例えば Main プロセスからウィンドウを生成する場合、以下のように __dirname からの相対で HTML のパスを指定することになる。

const mainWindow = new BrowserWindow( {
  width: 800,
  height: 600,
  resizable: true
} );

const filePath = Path.join( __dirname, 'index.html' );
mainWindow.loadUrl( 'file://' + filePath );

このとき --no-detect-globals を指定しないと __dirname がビルド環境の絶対・相対パス ( この分岐は他のオプションに影響される ) に置き換えられ、他の環境では動作しなくなる。

このオプションは Support for node-webkit – Issue #481 – substack/node-browserify にて Browserify 作者が言及しているのだけど、公式 README には --detect-globals しか掲載されていないのが少し不安ではある。

なお --insert-global-vars または --igv--igv \" \" のようにスペースひとつを指定することでグローバル変数の変更を回避できる。空文字だとダメだった。はじめは公式 README に掲載されている範囲で対策したくてこの方法を採用していたが、いかにも場当たりである。ならば --no-detect-globals のほうが作者による言及もあり信用できそうということで乗り換えた。

Browserify の –node オプション

Main プロセスで npm を読み込む場合、musicmetadata のように package.json で browser フィールドを指定しているものだと、そのスクリプトがエントリー ポイントになる。このような npm では main が Node、browser が Web ブラウザ向けに実装されている。

Main プロセスから呼び出す場合、npm も Node 向けに動作してほしいので、Browserify に --node オプションを指定する必要がある。

Renderer プロセスのビルド

Renderer は Main プロセスから起動され、指定された HTML ファイルがエントリー ポイントになる。HTML 上に読み込まれた JavaScript から remote や ipc といった Electron のモジュールを require して Main プロセスの機能を呼び出すことも可能。

Client-Server モデルで表すなら Client になるだろうか。

設定

Renderer プロセスのビルド設定を以下のように定義してみた。

{
  "scripts": {
    "build:js-renderer": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js -d | exorcist ./src/bundle.js.map > ./src/bundle.js",
    "watch:js-renderer": "watchify -v -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js -o \"exorcist ./src/bundle.js.map > ./src/bundle.js\" -d",
    "release:js-renderer": "browserify -t [ babelify --presets [ es2015 react ] ] ./src/js/renderer/App.js | uglifyjs -c warnings=false -d DEBUG=false > ./dist/src/bundle.js"
  }
}

Main プロセスと異なり、通常の Web アプリ的にビルド可能なので特筆すべきところはない。

{
  "scripts": {
    "watch:js-renderer": "watchify -v -t babelify ./src/js/renderer/App.js -o ./src/bundle.js -d",
  }
}

開発スタイル

アプリの開発スタイルは以下のようになる。

  • A. npm start を実行
    • JavaScript/CSS の自動コンパイル起動
    • Main プロセスのデバッグ用に node-inspector を起動
  • B.npm run app を実行
    • electron-prebuilt によりアプリが起動される
    • Main プロセスのコードを修正した場合はアプリを再起動
    • 再起動コマンドがない?ため、アプリを終了してから npm run app を再実行する
  • C. Chrome を起動して http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 にアクセス
    • node-inspector により Chrome のデバッガに Main プロセスが関連付けられる
    • node-inspector またはアプリが終了すると関連付けが解除される
    • node-inspector またはアプリを再起動した場合は Chrome のデバッガ用タブもリロードする必要あり

A と B はプロセスが異なるため、個別に実行する必要あり。OS X なら Terminal や iTerm で個別タブ、Windows なら PowerShell あたりで個別ウィンドウで実行する。どちらも中断方法は Ctrl + C となる。

C は Main プロセスのステップ実行が必要なら利用する。Chrome のデバッガ用タブにアプリ情報が表示されるまで数秒ほど待たされるのだが、これは結構ストレスである。

よって基本は console.log などを利用して開発し、問題箇所が特定できたら限定的にステップ実行というスタイルがよさそう。

app スクリプトは以下のように定義している。

{
  "scripts": {
    "app": "electron --debug=5858 src/"
  }
}

オプションを --debug から --debug-brk に変更することで Main プロセスの開始行をブレークできるそうだが、私の環境だと以下のエラーが発生してうまく動作しなかった。

TypeError: Cannot read property 'cwd' of undefined

関連付けに成功すると以下のようにブレークポイントを貼れる。

Main プロセスのデバッグ

Renderer プロセスはメイン メニューにブラウザのリロードとデバッガ起動するため項目を用意しているので必要に応じて利用する。こちらは Web フロントエンド的に JavaScript/CSS が自動コンパイルされたらリロードという感じで開発。

electron-connect があれば Live Reload できるそうだが、CLI がないのと Renderer プロセス側に仕掛けが必要 ( 接続用の処理 ) なので見送った。この機能に価値を感じるなら、タスク管理を gulp に移行して導入するのがよいだろう。watch と絡めやすそうなので。

パッケージ化

アプリをリリースするためのパッケージ化には electron-packager を利用する。他に electron-builder というモジュールもあるが、npm サイトでみると前者のほうがダウンロード数で勝るため一般的と判断して採用。

パッケージの元になるイメージは src/ を利用せず、必要なものだけ dist/src/ に集めてからビルドしている。これは src/ から不要なものを消すよりホワイトリスト式に収集するほうが楽という判断から。package.json をリリース用に分ける運用も、ここで生きてくる。JavaScript/CSS のコンパイル結果もここに出力。

electron-packager はビルドに Electron 本体を利用する。そのため必要なバージョンをダウンロードするのだが、その保存先は dist/cache/ にしている。--version へ指定するバージョン番号は Releases – atom/electron を見ながら判断する。予想外の互換問題を避けるため、このバージョンは開発用に package.json の dependencies へ指定している electron-prebuilt とあわせること。

余談だが、dependencies のバージョン管理には npm-check-updates が便利。インストールすると ncu -a で dependencies をまとめて最新版に更新できる。これで electron-prebuilt が更新されたら electron-packager の --version もあわせて変更するのもよいだろう。

ビルドにより生成されるパッケージは dist/bin/ へ出力。対象プラットフォームは --platform--arch の組み合わせで決定される。

アイコンのようにプラットフォームで分岐する設定 ( darwin = .icns、win = .ico ) もあるため、スクリプト自体を分けている。そのため --platform は個別。arch は現在 64bit OS が主流なのでそのようにしている。

なお --icon オプションによるアイコン設定は OS X だと成功するのだけど、Windows では謎のエラーが起きてしまい解決できなかったので諦めた。npm-debug.log を読むと EventEmitter とか ELIFECYCLE とかあるから、ビルド処理の制御そのものに問題があるのかもしれない。

.ico ファイルを変更してみたり、electron-builder を参考に homebrew で wine をインストールしてみたりしたけど改善されず。アイコン変更は重要な機能なので、これは継続調査する。

アイコンの Electron ロゴは sindresorhus/awesome-electron の SVG を利用。これを元に iDraw で加工 & PNG 書き出しをおこない iconutil コマンドで .icns 化した。Windows の .ico は Free Online Favicon and Apple Icons Generator – iconifier.net で作成、もしかしたらこれがエラー要因かもしれないので Windows 環境のアイコン生成系ツールを試してみる予定。

Windows 用にパッケージする場合は --version-string オプションも設定したほうがよい。これは実行ファイルのプロパティを Explorer などから表示したときの会社名やライセンスなどの文字列で、Visual Studio でいうバージョン情報と対応している。今回のサンプルでは面倒なので未指定。

--asar オプションを指定することで、アプリのリソース類 ( HTML/CSS/JS など ) をアーカイブ化してくれる。未指定だとリソースがそのままの構造で組み込まれる。OS X の場合は .app 内になるから目立ちにくいが、Windows や Linux だと resources/app/ 内にむき出しなので困るかも。

アーカイブ化されると resources/app.asar ファイルが生成される。これは Electron のパッケージ機能を利用しているようだ。electron/application-packaging.md に解説あり。そういえば --asar をつけても resources/default_app/ は残るのだけど、これは必要なのだろうか?

これらのスクリプトをまとめると以下のようになる。

{
  "scripts": {
    "release:css": "stylus -c ./src/stylus/App.styl -o ./dist/src/bundle.css",
    "release:js-main": "browserify -t babelify ./src/js/main/Main.js --im --no-detect-globals --node | uglifyjs > ./dist/src/main.js",
    "release:js-renderer": "browserify -t babelify ./src/js/renderer/App.js | uglifyjs > ./dist/src/bundle.js",
    "release:clean": "rimraf ./dist/src",
    "release:copy": "cpx \"./src/**/{*.html,*.eot,*.svg,*.ttf,*.woff,package.json}\" ./dist/src",
    "release:pack-osx": "electron-packager ./dist/src Starter --out=dist/bin --cache=dist/cache --platform=darwin --arch=x64 --version=0.33.3 --icon=res/app.icns --overwrite --asar",
    "release:pack-win": "electron-packager ./dist/src Starter --out=dist/bin --cache=dist/cache --platform=win32 --arch=x64 --version=0.33.3 --overwrite --asar",
    "release:pack-linux": "electron-packager ./dist/src Starter --out=dist/bin --cache=dist/cache --platform=linux --arch=x64 --version=0.33.3 --overwrite --asar",
    "release": "npm-run-all -s release:clean release:copy -p release:css release:js-main release:js-renderer -s release:pack-osx release:pack-win release:pack-linux"
  }
}

リリース用ビルドを実行する場合は npm run release を実行するだけでよい。

課題

環境構築において、以下の課題がある。

  1. Windows 版のアイコン設定
  2. Debug/Release 版でメニューなどの分岐

1 は前述のとおり。electron-packager や .ico について継続調査する。

2 は uglify-jsで顧客とのアプリ確認をもっと楽に! : アシアルブログ の方法で対応しようと考えたが、watchify で bundle しながら毎回 Uglify するのは非効率そうで二の足を踏んでいる。

これらについて、本記事を読まれてた方で妙案あれば、コメントなどで指摘していただけるとありがたい。

サンプル プロジェクト

最後に今回作成したサンプル プロジェクトを公開しておく。

アプリ部分はボタン押下による時刻表示の更新とリンクのクリックで URL を外部ブラウザに開く機能を実装。React/Flux と IPC による Main/Renderer 連携の簡単なサンプルになっている。


REPLY

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です