Electron 開発で得た知見まとめ

  • 174
    Like
  • 0
    Comment
More than 1 year has passed since last update.

はじめに

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

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

midnightSuyama/tweet-rec

実装

electron

atom/electron

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

ウインドウの表示

BrowserWindow を使用。
ウインドウサイズについて、大概の場合はコンテンツ領域を基準に考えるはずなので useContentSize は忘れずに。

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

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

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

また、electron.appwindow-all-closed イベントは default_app.js でアプリケーションが終了するようになっているので、常駐させるためにはこのイベントを上書きする。

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

メニューの表示

Menu を使用。
基本的には、公式のサンプルをベースにするだけで標準的なメニューが出来上がる。
OS X 特有のメニューも process.platform で切り分け、MenuItemrole で楽々。

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 を使用。
今回はウインドウを閉じても常駐する仕様なので、ここのメニューには終了するための手段を用意しておく。

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

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

二重起動の防止

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

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

ズームの防止

<meta name="viewport"> は効かなかったので、webFramewebFrame.setZoomLevelLimits を使った。

webFrame.setZoomLevelLimits(1, 1)

riot

riot/riot

各コンポーネントを app.js に集約させることで、
window.loadURL で読み込む 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 を用い、gulp で app.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 で装飾している箇所。
また、ここでは a タグにブラウザへの導線となる onclick イベントを付与しておいた。

<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

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

プロセス間で状態を保つために main プロセスでは、
subscribe で renderer プロセスへ共有を行い、ipcMain で受け取った action はそのまま store へ流す。

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

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

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

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

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

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

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

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

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

状態の保管

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

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

npm には存在していないため、bower でインストール。
npm で見つかる photon は同名の別物で、photonkit で登録する話は停滞中っぽい。

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

button:disabled {
  opacity: 0.5;
}

input:disabled {
  opacity: 0.5;
}

input type

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

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

gsap

greensock/GreenSock-JS

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

riot.mixin('schedule', {
  showSchedule: () => this.show(),
  hideSchedule: () => this.hide()
})
this.on('mount', () => {
  this.mixin('schedule')
  this.update()
})

dotenv

motdotla/dotenv

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

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

oauth

ciaranj/node-oauth

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

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 には全文が入っているので、リツイートに対しては下記のように組み立てることにした。

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

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

node-notifier

mikaelbr/node-notifier

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

babel

babel/babel

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

テスト

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

mocha

mochajs/mocha

定番のテストフレームワーク、gulp-mocha で定期実行させた。
es2015 で記述するためには babel-register を忘れずに。

chai

chaijs/chai

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

rewire

jhnns/rewire

private 要素へのアクセスに。

rosie

rosiejs/rosie

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

faker

marak/Faker.js

ダミーデータの生成に。

パッケージング

electron-packager

maxogden/electron-packager

対象フォルダの electron-packager, electron-prebuilt.git 以外を含めてパッケージングするため、対策を怠ると最終的なファイルサイズが肥大化してしまう。
肥大化の原因は node_modules にあるので、今回は Browserify で必要なコードをまとめ、--ignorenode_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

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

ImageMagickconvert を実行。

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

icns

*.iconset フォルダに Optimizing for High Resolution 記載のファイルを納めて iconutil を実行。

iconutil -c icns icon.iconset

まとめ

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

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

screenshot.png