アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

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

これまで 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/ 直下にリリース用として配置。ファイルを分ける理由はリリース用イメージに scriptsdependencies などの開発情報を含めたくないから。

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

この方針は NW.js の影響も受けている。NW.js ではウィンドウなどのアプリ設定自体が package.json に記述されるため開発とリリース用ファイルの分割は必然なのだが、すぐに慣れた。package.json が分かれていても namekeywords までなら変更はそれほど発生しないはず。リリース頻度にもよるが 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 にしているのは他にも Electron ビルド用のディレクトリが置かれるため。

Browserify の --im オプション

--im または --ignore-missing オプションを指定すると import/require の厳密なチェックが無効化される。対象が package.jsondependencies に定義されていれば 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.jsonbrowser フィールドを指定しているものだと、それがエントリー ポイントになる。このような npm では main が Node、browser が Web ブラウザー向けに実装されている。

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

Renderer プロセスのビルド

Renderer は Main プロセスから起動されて指定された HTML ファイルがエントリー ポイントになる。HTML 上に読み込まれた JavaScript から remoteipc といった 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 のデバッガ用タブもリロードする必要あり

AB はプロセスが異なるため個別に実行する必要あり。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 -adependencies をまとめて最新版へ更新できる。これで 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 の .icoFree 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 するのは非効率そうだから二の足を踏んでいる。

これらについて妙案あれば Twitter などで指摘していただけるとありがたい。

サンプル プロジェクト

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

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