はじめに
Electron + Riot + Redux でアプリケーションを作って得た知見まとめです。
制作物は Twitter の収録再生プレイヤー。
録画や録音しておいた Twitter 連動番組などを再生する際、放送当時のツイートをリアルタイムっぽく表示させる品です。
実装
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.app
の window-all-closed
イベントは default_app.js でアプリケーションが終了するようになっているので、常駐させるためにはこのイベントを上書きする。
app.on('window-all-closed', () => {})
メニューの表示
Menu を使用。
基本的には、公式のサンプルをベースにするだけで標準的なメニューが出来上がる。
OS X 特有のメニューも process.platform
で切り分け、MenuItem の role
で楽々。
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">
は効かなかったので、webFrame の webFrame.setZoomLevelLimits
を使った。
webFrame.setZoomLevelLimits(1, 1)
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
いろいろ模索した結果、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
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
React と同様に Riot もアニメーションは担当範囲外のため、アニメーション要素には TweenMax
を使用。
できる限りコンポーネントは疎にしておきたいので、別コンポーネントの表示アニメーションの呼び出しは Riot の mixin
を利用した。
riot.mixin('schedule', {
showSchedule: () => this.show(),
hideSchedule: () => this.hide()
})
this.on('mount', () => {
this.mixin('schedule')
this.update()
})
dotenv
実は main プロセスで呼び出しておくと process.env
は renderer プロセスにも伝わるので、初期値の共有としても便利。
.env
のパスは __dirname
を使って明示的に指定しておかないとパッケージング後に読み込めないので要注意。
require('dotenv').config({ path: __dirname + '/../.env' })
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}`
あと、ハッシュタグのストリーミングで取得困難な人をたまに見受けるので、定番のものを貼り付けておきます。
ストリーミングAPIでハッシュタグをトラックしたい場合、「#」を除いたハッシュタグをトラックして、流れるツイートのentitiesのハッシュタグに求めているハッシュタグが入っていないツイートをクライアント側で取り除くことがおすすめです。
— TwitterDevJP (@TwitterDevJP) 2013, 3月 27
node-notifier
renderer プロセスの Notification
ではなく、main プロセスから通知を行うために使用。
このモジュールには各 OS 向けの実行ファイルが含まれており、後述のパッケージングで注意するべき要素になる。
babel
基本的に *.js
は es2015 で記述して gulp-babel で変換して使用している。
そのため、今回作ったクラスも export default class
で定義して import from
で読み込もうとした所、default
が入ってこない。
これは babel の仕様が変わったためで、babel-plugin-add-module-exports を使うことで解消した。
テスト
今回のテストは単体限定で薄く書き Electron との関連性は無いため、使用したものだけを以下羅列。
mocha
定番のテストフレームワーク、gulp-mocha で定期実行させた。
es2015 で記述するためには babel-register
を忘れずに。
chai
mocha のアサーションとして assert
を。
rewire
private 要素へのアクセスに。
rosie
factory_girl にインスパイアされたファクトリ。
faker
ダミーデータの生成に。
パッケージング
electron-packager
対象フォルダの electron-packager
, electron-prebuilt
と .git
以外を含めてパッケージングするため、対策を怠ると最終的なファイルサイズが肥大化してしまう。
肥大化の原因は node_modules
にあるので、今回は Browserify で必要なコードをまとめ、--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
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 の convert
を実行。
convert icon.png -define icon:auto-resize icon.ico
icns
*.iconset
フォルダに Optimizing for High Resolution 記載のファイルを納めて iconutil
を実行。
iconutil -c icns icon.iconset
まとめ
以前、何かの勉強会で「サザエ実況はガチ」と聞いたとき、
ちょっと何を言っているのかわからなかったのですが、
#sazae を追ってみて理解できた。格が違う。
脚本家の名前で一喜一憂するとか想像すらしていなかったし、
今回得た知見で最も大きい収穫はコレかもしれない。
#sazae 実況の民、しゅごい。