Franz Pluginを作ってみよう

## Franzとは？

[Franz](http://meetfranz.com/)というアプリをご存知でしょうか。メッセンジャー/チャットサービスなどを集約して表示できるブラウザの一種です。アプリとしての詳しい説明は、いくつか日本語の記事もあるのでそちらをご覧下さい。

* http://www.danshihack.com/2016/03/07/junp/macapp-franz.html
* [Franzが面白い](http://memo.fushihara.net/post/150189331697/franz%E3%81%8C%E9%9D%A2%E7%99%BD%E3%81%84)

さて、普通に使った場合、便利じゃないということは無いのですが、日本語表記にも対応しているものの、サポートしているサービスも欧米で普及しているツールが中心のため、Slack等を使っていない人にとっては今一つピンとこない場合も多いと思います。

しかし、実はこのFranzには(あまりアピールされていないものの)プラグイン機構があり、任意のウェブサービスに対応させることが出来るのです。メッセージサービスは勿論のこと、情報が一覧表示されていて、時間経過で更新されていくようなコンテンツとの相性が良いため、RSSリーダーやソーシャルブックマーク、instagramやpixiv等のサイトを閲覧するのに大変便利です。

Franzのプラグイン機構はまだβ版で、バグがあったりまだ使えない機能もあったりするのですが、現状でも癖を把握すればいくつかの類型を応用するだけで簡単に任意のウェブサービスに対応するプラグインを開発できます。

## プラグインを使ってみる

プラグインを作る前に、まずは既存のプラグインを使ってみましょう。

1. [こちら](http://meetfranz.com/)からFranzをダウンロードしてインストール
2. 拙作の[Qiitaプラグイン](https://github.com/kan/franz-plugin-qiita)をチェックアウトする
3. Qiitaプラグイン中の`qiita`ディレクトリをFranzのプラグインディレクトリ下にコピー
4. Franzを起動(起動中の場合は再起動)

Franzのプラグインを配置するディレクトリは、OSによって異なるようで、公式のドキュメントによれば以下の通りです。

  * Mac: `~Library/Application Support/Franz/Plugins/`
  * Windows: `%appdata%/Franz/Plugins`
  * Linux: `~/.config/Franz/Plugins`
  * それ以外: Franzの設定ページで一番下に「Open Plugin Directory」というボタンがあるので、押すと該当ディレクトリが開く

なお、プラグインを新たに追加したり、後述するプラグイン内の`package.json`ファイルを更新した場合はFranzアプリを再起動しないと反映されないのでご注意下さい。

Franzの設定画面で追加するサービスの一覧の中にQiitaのアイコンが増えていると思いますので、クリックして設定をすすめてみて下さい。プラグインではQiitaで新着記事があった場合と、Qiitaの通知(Likeやコメントが付いた時)があった場合にFranz側にバッジで表示するようになっています。

## プラグインを作る

それでは、いよいよプラグインを自作していきたいと思います。現状、一番簡単にプラグインを書けるのは、以下の特徴を持つウェブサービスです

* JavaScript等で自動更新する
* ユーザーによって(閲覧しようとしているページの)URLが変わらない

正にQiitaがこの特徴を供えているので、前述のQiitaプラグインは大分シンプルに記述できました。

プラグインはディレクトリを作って、その下に以下のファイルを置くことになります。

* package.json
* index.js
* webview.js
* icon.png
* icon.svg

これ以外のファイルを置くことも出来ますが、これらのファイルは必須です。それぞれ解説していきます。

### icon.png / icon.svg

サービスのアイコンを設定します。アプリ内の殆どの場所ではicon.svgのほうが使われているようです。

個人的に使うプラグインを作る場合は、サイトのapple-touch-icon等に指定さているPNGファイルをダウンロードし、変換ツールを使ってSVGファイルを生成するのが楽です。広く公開することを考えている場合は各サイトのポリシーやライセンスに注意してください。フリーライセンスのアイコンを配布しているサイトのものを使う手もあります。

### package.json

プラグインの定義を記述するファイルです。項目が多そうに見えますが、いくつかのポイントを押さえるだけで動くプラグインは作れます。

```package.json
{
  "name": "qiita",
  "version": "1.0.0",
  "description": "qiita",
  "main": "index.js",
  "author": "Kan Fushihara <kan.fushihara@gmail.com>",
  "license": "MIT",
  "config": {
    "serviceURL": "https://qiita.com/",
    "serviceName": "Qiita",
    "message": "",
    "popup": [],
    "hasNotificationSound": true,
    "hasIndirectMessages": false,
    "hasTeamID": false,
    "customURL": false,
    "hostedOnly": false,
    "webviewOptions": {
      "disablewebsecurity": ""
    },
    "openDevTools": false
  }
}
```

プラグインを自作する場合、この内容をコピーして以下の項目だけ変更すれば良いです。

| 項目名       | 内容                   |
|:-----------:|:---------------------:|
| name        | プラグインの名前          |
| description | プラグインの概要          |
| author      | 自分の名前 <メールアドレス> |
| serviceURL  | 閲覧するサービスのURL     |
| serviceName | サービスの名前           |

また、openDevToolsをtrueにしておくと、該当サービスのタブをFranzで開いた時にChrome DevToolが開くのでデバッグし易くなるので、開発中はおすすめです(普段遣いする場合は鬱陶しいのでオフにしたくなると思います)。

### index.js

`package.json`で指定しているファイルで、Franzでサービスを追加する際に呼ばれます。後述するserviceURLが変化するプラグインでは、URLの妥当性をチェックする処理を行なう必要がありますが、serviceURLが固定の場合は定型の内容で問題ありません。

```index.js
module.exports = Franz => Franz;
```

### webview.js

Franzでサービスを閲覧する際に呼び出されるファイルです。現状は`Franz.loop`によって定期的に処理(未読チェック)を行なうことと、`Franz.injectCSS`によってサイトにカスタマイズ用のCSSを設定することくらいしか公式にはサポートされていませんが、サービスのDOMに対してJavaScriptであらゆる操作を行なうことが許されているので、頑張れば何でも出来ます(どう考えてもセキュリティ的に問題なので将来的にはsandbox的な機構が入って制限されると思いますが)。以下はQiitaプラグインのwebview.jsです。Qiitaの新着記事数の表示要素を取得して、`Franz.setBadge`によって未読カウントを通知しています。このようにDOM経由で未読を取得する他に、サイト内のJavaScriptで使われているデータオブジェクトを参照[^1]したり、Franzオブジェクトに記事の先頭要素を保存しておいて`getMessage`内で比較する[^2]等の戦略が考えられます。

```webview.js
module.exports = (Franz, options) => {
    let first_stmt = $($('#statements .statement')[0]).attr('class');
    Franz.latestStatement = $('div#statements .statement')[0].className;

    $(window).bind('scroll', (ev) => {
        Franz.latestStatement = $('div#statements .statement')[0].className;
    });

    function getMessages() {
        const res = $('#new-res-notice-text').text().match(/新着レスが(\d+)?個あります/);
        var reply = 0;
        if (res) {
            reply = res[1];
        }
        var unread = 0;
        const ls = $('div#statements .statement')[0].className;
        if (ls != Franz.latestStatement) {
            unread = 1;
        }

        Franz.setBadge(reply, unread);
    }

    Franz.loop(getMessages);
}
```

[^1]: https://github.com/meetfranz/plugins/blob/develop/feedbin/webview.js#L3
[^2]: https://github.com/kan/franz-plugin-massr/blob/master/webview.js

## プラグインを作る(カスタムURL編)

ウェブサービスの中には、ユーザー(チーム)毎に違うドメイン、パスでサービスを提供しているものがあります。SlackやQiita:Team、はてな等ですね。これらのサービス用のプラグインを作る場合、package.jsonの`serviceURL`が一意に定まりません。Franzではそういった場合の対応を用意してくれています(ややバグっていますが)。

### package.json

以下にQiita:Team用プラグインのpackage.jsonを載せています。hasTeamIDフラグをオン(true)にした上でserviceURLの記述を変えているのがポイントです。

```package.json
{
  "name": "qiita-team",
  "version": "1.0.0",
  "description": "qiita-team",
  "main": "index.js",
  "author": "Kan Fushihara <kan.fushihara@gmail.com>",
  "license": "MIT",
  "config": {
    "serviceURL": "https://{teamID}.qiita.com/",
    "serviceName": "Qiita:Team",
    "message": "",
    "popup": [],
    "hasNotificationSound": true,
    "hasIndirectMessages": false,
    "hasTeamID": true,
    "wording": {
        "url": "qiita.com",
        "team": "Qiita"
    },
    "webviewOptions": {
      "disablewebsecurity": ""
    },
    "openDevTools": false
  }
}
```

なお、現状このteamIDを入力するモードはドメインのprefixとしてteamIDを付与するスタイルしか想定していないようで、はてなのようにURLのパス部分にIDが含まれるようなパターンに対応できません。また、自分で設置して運用するWebアプリのように、URLが完全に不定なものもあるかと思います。そういった場合は以下のようなpackage.jsonになります。

```package.json
{
  "name": "massr",
  "version": "1.0.0",
  "description": "massr",
  "main": "index.js",
  "author": "Kan Fushihara <kan.fushihara@gmail.com>",
  "license": "MIT",
  "config": {
    "serviceURL": "",
    "serviceName": "Massr",
    "message": "",
    "popup": [],
    "hasNotificationSound": true,
    "hasIndirectMessages": false,
    "hasTeamID": true,
    "customURL": true,
    "hostedOnly": true,
    "webviewOptions": {
      "disablewebsecurity": ""
    },
    "openDevTools": false
  }
}
```

`serviceURL`を空にし、`customURL`、`hostedOnly`をtrueにします。`hasTeamID`は意味を考えるとfalseでも大丈夫そうなのですが、現状(Franz 4.0.4)では指定しないと上手く動きません。

### index.js

serviceURLが不定の場合、変なURLが入力されていないか`index.js`でチェックすることになります。

```index.js
module.exports = (Franz) => {
    class Massr extends Franz {
        validateServer(URL) {
            const api = `${URL}`;
            return new Promise((resolve, reject) => {
                $.get(api, (resp) => {
                    resolve();
                }).fail(reject);
            });
        }
    }

    return Massr;
};
```

この例ではユーザーがサービスURLとして設定したURLへアクセスし、ステータス200が返ってきたら問題ないと判断しています。公式リポジトリでのやりとりを見る限り、実際にはトップページよりも高速に取得できるファイル(xxx.jsonのような)やAPIがあった場合、そちらのURLをチェックするほうが推奨されるようです。

## プラグイン向けTips

### 定期リロード

[公式プラグインリポジトリのドキュメント](https://github.com/meetfranz/plugins/blob/develop/docs/integration.md#user-content-roadmap)によると、将来的にはFranz自体がページの自動更新をサポートするようです。(余談ですが、この一覧を観てみると、プラグインのマーケット機能やi18n対応なども予定があるようで、楽しみですね)。

とはいえ、現状その機能はないので実装されるまでは自分でなんとかする必要があります。

```js
setTimeout(() => {
    location.reload();
}, 9000); // reload 90s
```

この記事で紹介してるwebview.jsの中にも既にあった記述かと思いますが、webview.jsでsetTimeoutを使って一定時間後にリロードをかけるのが一番簡単です(リロードするとwebview.js自体も再評価されるので、setIntervalではなくsetTimeoutで大丈夫です)。リロードの間隔をハードコーディングしないといけないのがダサいですが、こちらも[プラグイン独自の設定ができる仕様の提案](https://github.com/meetfranz/plugins/issues/8)が上がっているようです。

リロードの注意点としては、`docuement.location.reload()`でなく`location.reload()`としてるところです。どうもFranz内のwebviewの仕様なのか、前者を使ってリロードしてしまうと表示に不都合が生じてしまうようです。

### 新着チェックの永続化

webview.js内でサービスの新着をチェックする場合、既存のデータを変数等に保持しておいて最新データと比較するパターンが多いかと思いますが、前述のようにリロードをかける場合、変数に入れておいたデータは消えてしまい、更新のたびに新着がリセットされてしまうことになります。

これを回避する一番簡単な方法はLocal Storageに入れてしまうことです。Local Storageに値が入っていない場合に最新データを`localStorage.setItem`で登録し、以降は`localStorage.getItem`で参照して最新データと比較します。そのままだと新着の通知が永久に消えなくなってしまうので、筆者はページのスクロールイベントにフックしてデータをリセットするようにしています。

```js
// jQueryが使える場合
$(window).bind('scroll', (ev) => {
    localStorage.setItem("key of item", newValue);
});
```

## まとめ

メッセンジャーやフローデータのリーダーとしての可能性を秘めているFranzのプラグインの作り方についてまとめてみました。
この記事はFranz 4.0.4時点のものであり、活発に開発が進んでいるため今後プラグインの仕様を含めてどんどん変わっていく可能性が高いです。本記事も重大な変更についてなるべく追随していきたいと思っていますが、最新の情報は公式のリポジトリを参照して下さい。