Electron 開発で得た知見まとめ

# はじめに

Electron + Riot + Redux でアプリケーションを作って得た知見まとめです。

制作物は Twitter の収録再生プレイヤー。
録画や録音しておいた Twitter 連動番組などを再生する際、放送当時のツイートをリアルタイムっぽく表示させる品です。

[midnightSuyama/tweet-rec](https://github.com/midnightSuyama/tweet-rec)

# 実装

## electron

[atom/electron](https://github.com/atom/electron)

Twitter の収録はウインドウ有無に依存しないよう main プロセスで行い、renderer プロセスでは表示に専念する方針とした。
ウインドウを閉じても常駐するタイプのアプリケーション。

### ウインドウの表示

[BrowserWindow](https://github.com/atom/electron/blob/master/docs/api/browser-window.md) を使用。
ウインドウサイズについて、大概の場合はコンテンツ領域を基準に考えるはずなので `useContentSize` は忘れずに。

ウインドウを閉じた際には解放しておきたいので `closed` イベントでは null を入れておき、表示が必要なタイミングではウインドウ有無の判定材料とした。

```js
mainWindow.on('closed', () => {
  mainWindow = null
})

if (mainWindow) {
  if (mainWindow.isMinimized()) mainWindow.restore()
  mainWindow.focus()
} else {
  mainWindow = new BrowserWindow()
}
```

また、`electron.app` の `window-all-closed` イベントは [default_app.js](https://github.com/atom/electron/blob/master/atom/browser/default_app/default_app.js) でアプリケーションが終了するようになっているので、常駐させるためにはこのイベントを上書きする。

```js
app.on('window-all-closed', () => {})
```

### メニューの表示

[Menu](https://github.com/atom/electron/blob/master/docs/api/menu.md) を使用。
基本的には、公式のサンプルをベースにするだけで標準的なメニューが出来上がる。
OS X 特有のメニューも `process.platform` で切り分け、[MenuItem](https://github.com/atom/electron/blob/master/docs/api/menu-item.md) の `role` で楽々。

```js
if (process.platform === 'darwin') {
  template.unshift({
    submenu: [
      { label: `About ${app.getName()}`, role: 'about' },
      { type: 'separator' },
      { label: 'Services', role: 'services', submenu: [] },
      { type: 'separator' },
      { label: `Hide ${app.getName()}`, accelerator: 'Command+H', role: 'hide' },
      { label: 'Hide Others', accelerator: 'Command+Alt+H', role: ideothers' },
      { label: 'Show All', role: 'unhide' },
      { type: 'separator' },
      { label: 'Quit', accelerator: 'Command+Q', click: () => app.quit() }
    ]
  })
}
```

### タスクトレイ / メニューバーの表示

[Tray](https://github.com/atom/electron/blob/master/docs/api/tray.md) を使用。
今回はウインドウを閉じても常駐する仕様なので、ここのメニューには終了するための手段を用意しておく。

アイコンの画像サイズは OS によって異なるので `process.platform` で切り分け、表示領域の背景色を考慮して Windows は白、OS X は黒で作成するのが吉。
このアイコン画像である [NativeImage](https://github.com/atom/electron/blob/master/docs/api/native-image.md) のパスは直書きしてしまうとパッケージング後の実行環境でズレが生じるため、`__dirname` を使って指定しておかないとハマる。

```js
let image = path.join(__dirname, '..', 'res', (process.platform === 'darwin' ? 'tray-22.png' : 'tray-32.png'))
```

### 二重起動の防止

起動時に `app.makeSingleInstance` を実行して、既に起動中であれば自身を終了させた。

```js
var shouldQuit = app.makeSingleInstance((argv, workingDirectory) => {
})
if (shouldQuit) app.quit()
```

### ズームの防止

`<meta name="viewport">` は効かなかったので、[webFrame](https://github.com/atom/electron/blob/master/docs/api/web-frame.md) の `webFrame.setZoomLevelLimits` を使った。

```js
webFrame.setZoomLevelLimits(1, 1)
```

## riot

[riot/riot](https://github.com/riot/riot)

各コンポーネントを `app.js` に集約させることで、
`window.loadURL` で読み込む HTML は下記のようになった。

```html
<body>
  <app></app>
  <script src="../bower_components/riot/riot.min.js"></script>
  <script src="app.js"></script>
  <script>riot.mount('app')</script>
</body>
```

`*.tag` のコンパイルには [gulp-riot](https://github.com/e-jigsaw/gulp-riot) を用い、gulp で `app.js` を生成することにした。

```js
gulp.task('riot', () => {
  gulp.src('src/tags/*.tag')
    .pipe(plumber())
    .pipe(riot())
    .pipe(concat('app.js'))
    .pipe(babel())
    .pipe(uglify())
    .pipe(gulp.dest('dist'))
})
```

### HTML エスケープ

Riot は HTML エスケープがデフォルト有効で、アンエスケープ文字が存在しないため、装飾範囲については別タグの innerHTML で対応させる。
今回の例で言うと、ツイートを [twitter-text](https://github.com/twitter/twitter-text) で装飾している箇所。
また、ここでは `a` タグにブラウザへの導線となる `onclick` イベントを付与しておいた。

```html
<twitter-text>
  <script>
    const twitterText = require('twitter-text')
    this.root.innerHTML = twitterText.autoLink(twitterText.htmlEscape(opts.text))

    let a = this.root.getElementsByTagName('a')
    for (let i=0; i<a.length; i++) {
      a[i].setAttribute('onclick', 'electron.shell.openExternal(this.href); return false;')
    }
  </script>
</twitter-text>
```

## redux

[rackt/redux](https://github.com/rackt/redux)

いろいろ模索した結果、`action` は両プロセスで共有、
`reducer`, `store` は main プロセスに置き `middleware` を厚くする方針で落ち着いた。

プロセス間で状態を保つために main プロセスでは、
`subscribe` で renderer プロセスへ共有を行い、[ipcMain](https://github.com/atom/electron/blob/master/docs/api/ipc-main.md) で受け取った `action` はそのまま `store` へ流す。

```js
store.subscribe(() => {
  if (mainWindow) {
    mainWindow.webContents.send('update-state', store.getState())
  }
})

ipcMain.on('dispatch-action', (event, action) => {
  store.dispatch(action)
})
```

さらに、ウインドウ作成時にも反映させるため `did-finish-load` イベントでも共有するようにしておく。

```js
mainWindow.webContents.on('did-finish-load', () => {
  mainWindow.webContents.send('update-state', store.getState())
})
```

そして、renderer プロセスでは共有された状態を反映させ、発行する `action` は全て main プロセスへ送るようにする。

```js
ipcRenderer.on('update-state', (event, state) => {
  global.state = state
  this.update()
})

ipcRenderer.send('dispatch-action', action)
```

これで経路が確立できたものの、`action` がプロセス間をまたぐため [Redux Thunk](https://github.com/gaearon/redux-thunk) などの遅延評価が使えない。
そのため、この役回りは `middleware` 層へ `action.type` に応じた処理を記述し、対象外のものはそのまま通すことで代用した。

```js
store => next => action => {
  switch(action.type) {
    default:
      next(action)
  }
}
```

### 状態の保管

今回の状態管理は main プロセスが中心、ウインドウには依存しないため LocalStorage や IndexedDB を使うわけにはいかず、JSON ファイルで保存することにした。
`app.getPath('cache')` で OS ごとのキャッシュパスが取得できるので、`subscribe` で保存しておき、復元は起動時に読み込むだけ。

```js
const stateCachePath = path.join(app.getPath('cache'), app.getName(), 'state.json')

store.subscribe(() => {
  mkdirp.mkdirpAsync(path.dirname(stateCachePath)).then(() => {
    fs.writeFile(stateCachePath, JSON.stringify(store.getState()))
  })
})
```

## photon

[connors/photon](https://github.com/connors/photon)

npm には存在していないため、bower でインストール。
npm で見つかる photon は同名の別物で、[photonkit](https://github.com/connors/photon/issues/37#issuecomment-148857370) で登録する話は停滞中っぽい。

今回はこの CSS をベースに全体に関わる修正をしつつ、各コンポーネント固有の修正は Riot の `<style scoped>` を活用した。
現 photon では `disabled` 時の表示変化が無いため、この部分のみ全体に関わる修正として記述した。

```css
button:disabled {
  opacity: 0.5;
}

input:disabled {
  opacity: 0.5;
}
```

### input type

普段は使用を躊躇する input type の類も、Electron では Chromium 環境に限られるため、今回は `range`, `date`, `time` を使用し、CSS ライブラリは photon のみで完結できた。
特に `range` はシークバーとして扱った結果、使い勝手も良く重宝した。

```html
<input type="range" name="seek" value={ currentTime } min={ beginTime } max={ endTime } step="1000" onchange={ seekOnChange }>
```

## gsap

[greensock/GreenSock-JS](https://github.com/greensock/GreenSock-JS)

React と同様に Riot もアニメーションは担当範囲外のため、アニメーション要素には `TweenMax` を使用。
できる限りコンポーネントは疎にしておきたいので、別コンポーネントの表示アニメーションの呼び出しは Riot の `mixin` を利用した。

```js
riot.mixin('schedule', {
  showSchedule: () => this.show(),
  hideSchedule: () => this.hide()
})
```

```js
this.on('mount', () => {
  this.mixin('schedule')
  this.update()
})
```

## dotenv

[motdotla/dotenv](https://github.com/motdotla/dotenv)

実は main プロセスで呼び出しておくと `process.env` は renderer プロセスにも伝わるので、初期値の共有としても便利。
`.env` のパスは `__dirname` を使って明示的に指定しておかないとパッケージング後に読み込めないので要注意。

```js
require('dotenv').config({ path: __dirname + '/../.env' })
```

## oauth

[ciaranj/node-oauth](https://github.com/ciaranj/node-oauth)

Twitter について、普段はコールバック経由で認証することが多いものの、今回はピンコード方式を採用した。
使い慣れたブラウザを開き、Twitter にログイン済みであれば認可するだけ、ピンコードを見ながら Electron 製アプリケーションへ入力、という流れは UX 的に良いかもしれない。
node-oauth では `authorize_callback` を null にすることでピンコード方式となる。

```js
const OAuth = require('oauth').OAuth
const oauth = new OAuth(
  'https://api.twitter.com/oauth/request_token',
  'https://api.twitter.com/oauth/access_token',
  process.env.twitter_consumer_key,
  process.env.twitter_consumer_secret,
  '1.0',
  null,
  'HMAC-SHA1'
)
```

### Twitter Streaming API

リツイートについて、`text` には `RT @screen_name: ` が付与されるだけだと思っていたものの、付与された結果 140 文字を超えたものは三点リーダーで省略されており、この現象は今回初めて気づいた。
`retweeted_status.text` には全文が入っているので、リツイートに対しては下記のように組み立てることにした。

```js
`RT @${data.retweeted_status.user.screen_name}: ${data.retweeted_status.text}`
```

あと、ハッシュタグのストリーミングで取得困難な人をたまに見受けるので、定番のものを貼り付けておきます。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">ストリーミングAPIでハッシュタグをトラックしたい場合、「＃」を除いたハッシュタグをトラックして、流れるツイートのentitiesのハッシュタグに求めているハッシュタグが入っていないツイートをクライアント側で取り除くことがおすすめです。</p>&mdash; TwitterDevJP (@TwitterDevJP) <a href="https://twitter.com/TwitterDevJP/status/316742574054457346">2013, 3月 27</a></blockquote>

## node-notifier

[mikaelbr/node-notifier](https://github.com/mikaelbr/node-notifier)

renderer プロセスの `Notification` ではなく、main プロセスから通知を行うために使用。
このモジュールには各 OS 向けの実行ファイルが含まれており、後述のパッケージングで注意するべき要素になる。

## babel

[babel/babel](https://github.com/babel/babel)

基本的に `*.js` は es2015 で記述して [gulp-babel](https://github.com/babel/gulp-babel) で変換して使用している。
そのため、今回作ったクラスも `export default class` で定義して `import from` で読み込もうとした所、`default` が入ってこない。
これは babel の仕様が変わったためで、[babel-plugin-add-module-exports](https://github.com/59naga/babel-plugin-add-module-exports) を使うことで解消した。

# テスト

今回のテストは単体限定で薄く書き Electron との関連性は無いため、使用したものだけを以下羅列。

## mocha

[mochajs/mocha](https://github.com/mochajs/mocha)

定番のテストフレームワーク、[gulp-mocha](https://github.com/sindresorhus/gulp-mocha) で定期実行させた。
es2015 で記述するためには `babel-register` を忘れずに。

## chai

[chaijs/chai](https://github.com/chaijs/chai)

mocha のアサーションとして `assert` を。

## rewire

[jhnns/rewire](https://github.com/jhnns/rewire)

private 要素へのアクセスに。

## rosie

[rosiejs/rosie](https://github.com/rosiejs/rosie)

factory_girl にインスパイアされたファクトリ。

## faker

[marak/Faker.js](https://github.com/marak/Faker.js)

ダミーデータの生成に。

# パッケージング

## electron-packager

[maxogden/electron-packager](https://github.com/maxogden/electron-packager)

対象フォルダの `electron-packager`, `electron-prebuilt` と `.git` 以外を含めてパッケージングするため、対策を怠ると最終的なファイルサイズが肥大化してしまう。
肥大化の原因は `node_modules` にあるので、今回は [Browserify](http://browserify.org) で必要なコードをまとめ、`--ignore` に `node_modules` を加えることで 100 MB 程度縮小された。

    electron-packager . tweet-rec --platform=win32,darwin --arch=x64 --version 0.36.7 --out release --app-version 1.0.0 --icon res/icon --ignore '^/(src|test|node_modules|release)/' --asar --asar-unpack-dir vendor --overwrite

## browserify

[substack/node-browserify](https://github.com/substack/node-browserify)

main プロセスと renderer プロセス、それぞれ分けて実行し、結果はライセンス表記を残して圧縮した。

### main

    browserify --im --no-detect-globals --node dist/main.js | uglifyjs -o dist/main.js --comments

### renderer

    browserify --im --no-detect-globals --node dist/app.js | uglifyjs -o dist/app.js --comments

### vendor

モジュールの中には JavaScript 以外のプログラムに依存しているものもあり、今回の例で言うと node-notifier が該当する。
当然ながら Browserify の対象外のため、必要なファイルはパスが合う位置に `node_modules` から移しておく。

    cp -r node_modules/node-notifier/vendor .

また、このファイルは `asar` に含めることはできないので、`--asar-unpack-dir` で除外する。

## icon

`electron-packager` の `--icon` は拡張子を省略すると OS ごとに判別してくれるので、各アイコン画像のファイル名は統一しておく。
ただし、開発環境が Windows 以外の場合は wine に依存するため要インストール。
下記は OS X 環境でのアイコン生成例。

### ico

[ImageMagick](http://www.imagemagick.org/script/index.php) の `convert` を実行。

    convert icon.png -define icon:auto-resize icon.ico

### icns

`*.iconset` フォルダに [Optimizing for High Resolution](https://developer.apple.com/library/mac/documentation/GraphicsAnimation/Conceptual/HighResolutionOSX/Optimizing/Optimizing.html) 記載のファイルを納めて `iconutil` を実行。

    iconutil -c icns icon.iconset

# まとめ

以前、何かの勉強会で「サザエ実況はガチ」と聞いたとき、
ちょっと何を言っているのかわからなかったのですが、
\#sazae を追ってみて理解できた。格が違う。
脚本家の名前で一喜一憂するとか想像すらしていなかったし、
今回得た知見で最も大きい収穫はコレかもしれない。

\#sazae 実況の民、しゅごい。

![screenshot.png](https://qiita-image-store.s3.amazonaws.com/0/54402/3240512d-a1eb-a743-32c3-c920724de74a.png)