はじめに

こんにちは、@tsuwatchです。普段はRubyを書くのですが、仕事の幅も広がりつつあり、フロントエンドも本格的にやっていこうということで、
Kaizokuというニコニコ生放送のデスクトップアプリをリリースしました。
人生の大半の時間がニコ生に溶けているわけですが、かねてからコメントビューワを作ろうと思っていたので、この機会に作ってみました。
しかし、Mac版のコメントビューワにはHakumaiという大変素晴らしいコメントビューワが存在するので、少し違う方向を向いた生放送ビューワをかねたアプリにしました。
Hakumaiはコメントビューワとしては数少ないオープンソースなので、実装やコメントサーバの仕様など大変参考にさせて頂きました。この場をお借りして、お礼を申し上げます。
アプリの機能や今後についての紹介はまた別途ブログで書くと思います。
ご興味がありましたら、ぜひ使ってみていただければと思います。

preview.png

preview2.png

少し長くなりましたが、本編です。まず、

開発環境について

主なライブラリとして

  • Electron
  • Webpack
  • Almin
  • CSS Modules
  • fontawesome

あたりを使用しています。

ステート管理ライブラリの選定について

今回はAlminというライブラリを使用しました。
Reduxは使用したことがあるというのと、本質とは関係ない複雑さがあったり、Reduxのことを考えるのがイヤで、他のシンプルなライブラリがないか探していました。
他にもflux utilが候補に上がったのですが、とにかくPOJOなモデルを作ってやっていきたいと思っていて、dispatchを使用するライブラリではRedux同様POJOを扱いにくく断念しました。

そして最終的には、社内でもニコニコ動画のHTML5プレイヤーに使われていて1、実用実績もあるので、Alminを採用することにしました。

Almin良いところは、色々あると思うのですが

  • ドメインロジックを書くところが用意されている
  • DDDでレイヤーが適切にわけられている
  • 処理にuseCaseとして名前がつけられ見通しが良い
  • 処理の流れが、CQRSが良い
  • ログが大変わかりやすい

あたりが大きく良かった点です。詳しくは作者のかたが詳細に紹介していますので、それを読むのが良いと思います2

CSSライブラリの選定について

今回はCSS Modulesを使用しています。
グローバルに書いて命名規則でどうにかするみたいなことをするのはちょっとよくわかりませんし、当時はとにかくはやく完成させたかったので、特にややこしいことをしていません。
作り始めたのが10ヶ月くらい前なので、今選ぶとしたらstyled-compontentsを選ぶと思います。
CSS Modulesを使用していると、コンポーネント内でステートによってクラスをつけかえたり見た目に関するロジックが出てきてしまうのですが、styled-componentsを使用すると、コンポーネントをつくって、それにスタイルをあてるので、見た目に関するロジックがこのコンポーネントに押し込めることができるので良いです。

Electronに関する知見

また少し話はそれましたが、開発していて出てきた苦労やハマったポイントなど、Electronに関する知見を紹介したいと思います。

ネイティブモジュールのビルド

Kaizokuではsqlite3など、ネイティブのモジュールを使用しています。
これらは、単にインストールしただけでは使用できず、electron-rebuildを使用して、Electron用にrebuildしなければいけません。

./node_modules/.bin/electron-rebuild -m sqlite3

ChromeからCookieを取得する

Kaizokuでは、ログイン時に既存のChromeのCookieを取得して、それを使用するという方法があります。このため、ChromeのCookieを取得する必要があるのですが、それが暗号化されており、単純に取得することができず、複合してやる必要があります。
KaizokuでのCookieの取得処理はこんな感じになっています。

import fs from 'fs';
import sqlite3 from 'sqlite3';
import path from 'path';
import keytar from 'keytar';
import crypto from 'crypto';

export default class SessionExtractor {
  constructor(browser) {
    this.browser = browser;
  }

  extract() {
    return new Promise((resolve, reject) => {
      this._connect();
      this.db.serialize(() => {
        Promise.resolve()
          .then(() => {
            return this.getEncryptedUserSession();
          })
          .then((value) => {
            this.db.close();
            resolve(this.decryptUserSession(value));
          })
          .catch((err) => {
            this.db.close();
            reject(err);
          });
      });
    });
  }

  getEncryptedUserSession() {
    return new Promise((resolve, reject) => {
      this.db.get("SELECT * FROM cookies WHERE host_key = '.nicovideo.jp' AND name = 'user_session'", (err, row) => {
        if (err || !row) {
          reject(err);
          return;
        }

        resolve(row.encrypted_value);
      });
    });
  }

  decryptUserSession(encryptedValue) {
    return new Promise((resolve, reject) => {
      const password = keytar.getPassword('Chrome Safe Storage', 'Chrome');

      crypto.pbkdf2(password, 'saltysalt', 1003, 16, 'sha1', (err, key) => {
        if (err) {
          reject(err);
          return;
        }

        const iv = new Array(17).join(' ');
        const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
        resolve(decipher.update(encryptedValue.slice(3)) + decipher.final());
      });
    });
  }

  _connect() {
    const dbPathBase = path.resolve(path.join(process.env.HOME, "Library/Application Support/Google/Chrome"));
    let dbPath = path.join(dbPathBase, 'Default');
    try {
      fs.statSync(dbPathBase);
      if (!fs.statSync(dbPath).isDirectory) dbPath = `${dbPathBase}/Profile 1`; // ChromeでGoogleにログインしていない場合
      fs.statSync(dbPath);
    } catch(err) {
      throw err;
    }
    this.db = new sqlite3.Database(`${dbPath}/Cookies`);
  }
}

ファイルサイズの最適化

Electronはchromiumをベースにしており、Hello Worldだけするような単純なアプリでも100M近くのファイルサイズになってしまいます。油断しているとどんどんファイルサイズは肥大化してしまいますので、できるだけ小さくすることを心がける必要があります。

まず、Electronに必要なファイルはpackage.jsonとアプリを立ち上げるmain.jsです。これ以外は必要ありません。
なので、electron-packagerなどでパッケージングするときは必ず、ビルドした結果生成されたファイルのみをパッケージングするようにします。ルートディレクトリにあるファイル全部をパッケージングして含めてしまっているのをよくみます。注意してください。Kaizokuではこんな感じになっています。

NODE_ENV=production ./node_modules/.bin/electron-packager ./app Kaizoku --platform=darwin --arch=x64 --icon=icons/app.icns --out=packages --overwrite --asar.unpack=**/PepperFlashPlayer.plugin/**

--asarオプションを使用することで、asar圧縮をすることができます。生成されたファイルはKaizokuだとKaizoku.app/Contents/Resourcesのapp以下で確認することができます。asar圧縮していれば、app.asarというファイルになっています。ちゃんと最低限にファイルだけになっているかをここで確認することができます。

ネイティブモジュールが存在する場合

しかし、webpackでexternalsで指定しているようなネイティブモジュールが存在する場合は、そのネイティブモジュールもapp以下に含めなければなりません。なので、自分の場合はapp以下に配置しているpackage.jsonを使用して、再度npm installelectron-rebuildを実行しており、node_modulesが全てパッケージングに含まれている状態になっています…。

Flashが存在する場合

Flashはパッケージングのさいにasar圧縮の対象にすることはできません。なので上記コマンドのようにunpackしてやる必要があるのでご注意ください。
ちなみに(少しハマったので)自分はアプリ内でFlashをこういう感じで参照しています。

const pluginDir = path.join(__dirname, process.env.NODE_ENV === 'development' ? '../' : '', 'plugins').replace('app.asar', 'app.asar.unpacked');
app.commandLine.appendSwitch('ppapi-flash-path', path.join(pluginDir, 'PepperFlashPlayer.plugin'));

自動アップデートについて

ElectronにはautoUpdaterというAPIがあり、MacとWindowsで自動的にアプリを更新することができます3
MacとWindowsではやり方が違っていて、非常に面倒なのですが、今回はMacでの方法を紹介したいと思います。
自動アップデートを行うのに必要なモノは、

  • 署名済み.appのみを含んだzipファイル
  • zipファイルを置いておくところ
  • 新しいアプリが存在するかを確認できるAPI

を用意する必要があります。

まず、アプリの署名について説明します。

署名

まず署名を行うには、Apple Developer Program(有料)に登録する必要があります。
そして、https://developer.apple.com/account/mac/certificate
左メニューの Certificates -> 右上の+ボタン -> Production -> Developer ID -> Developer ID Application
あとは指示に従って証明書を作成し、ダウンロードします。
そして、パッケージングするときに指定します。自分はelectron-packagerを使用しているので以下のように--osx-signで指定しています。

NODE_ENV=production ./node_modules/.bin/electron-packager ./app Kaizoku --platform=darwin --arch=x64 --icon=icons/app.icns --out=packages --overwrite --asar.unpack=**/PepperFlashPlayer.plugin/** --osx-sign='Developer ID Application: xxx yyy (zzz)'

署名については、 https://github.com/electron-userland/electron-osx-sign/wiki とかにも詳しく書かれています。

次に、更新が存在するかを確認するためのWebAPIを用意する必要があります。

Web API

autoUpdaterが指定しているフォーマットのJSONを返すAPIであれば何でも大丈夫です。

アップデートあり

200 OK

{
  "url": "https://github.com/tsuwatch/kaizoku/releases/download/v0.7.0/Kaizoku-darwin-x64.zip",
  "name": "Kaizoku v0.7.0",
  "notes": "- アプリの自動更新を実装"
}
アップデートなし

204 No Content

このように返します。他にもJSONで指定できるkeyがあるので見てみてください。

用意の仕方は色々あると思うのですが、ざっと見た感じだと、

あたりが便利そうです。
今回は、そこまでコストをかけたくなかったので、electron-gh-releasesを使用しています。
これは、autoUpdaterのラッパーで、返すJSONをGitHubのリポジトリに登録し、releasesにzipをアップロードするだけで良いのでコストがかからず便利です。
こんな感じで実装できます。
スクリーンショット 2017-12-15 22.29.32.png

パッケージングについて

アイコンの設定

Macのアイコンは、icnsというさまざまなサイズのpngが必要なこれまた面倒なファイルを用意しないといけないのですが、今回はicon-genを使用して、svgからicnsを作成しています。

./node_modules/.bin/icon-gen -i ./icons/icon.svg -o ./icons -m icns -r

他プラットフォームへのパッケージング

KaizokuはWindowsとLinux向けにパッケージを用意する予定でしたが、プラットフォーム別にネイティブモジュールのクロスコンパイルができないことを知り、今回は一旦見送りました。

Mac App Store(MAS)への登録

難しい。
やり方は、https://electronjs.org/docs/tutorial/mac-app-store-submission-guide などに書かれているのですが、おそらくplistや権限、署名の理解や含んでいるFlashなどのファイルの扱いが原因でスッとできなくて間に合いませんでした。これだけで結構な記事になりそうです。

副産物の紹介

今回Kaizokuをつくるのにできたライブラリを紹介します。

  • nicolive-api
    • ニコニコ生放送周辺のAPIを叩けるnpmライブラリ
    • コメントサーバへはもちろん、ニコ生アラートサーバへの接続や、その他ユーザー、コミュニティ、チャンネル情報も取得できます
    • コメントビューワをつくるときにはぜひ
  • niconico-search
    • niconicoコンテンツ検索APIを叩くnpmライブラリ
    • ニコニコは動画や生放送などのコンテンツを検索できるAPIがあり、これを利用することができます(まだ生放送にしか対応していませんが、簡単に他サービスも実装できるようにしています
    • 既存のものは古かったりしたのでつくりました

最後に

リリース作業が一番辛かったですね。
ご意見、ご要望、バグなどありましたら、ぜひissueや直接言っていただければ泣いて喜びます。

どうぞ、よろしくお願いいたします。

https://tsuwatch.github.com/kaizoku