ElectronとGoogle Calendar APIを使ってデスクトップアプリを作ってみた

## はじめに

今年もアドベントカレンダーはじまりました！
この記事は[Hamee Advent Calendar 2017](http://qiita.com/advent-calendar/2017/hamee)の1日目の記事です。
毎年この時期になると「アドカレやろうぜ！」と誘いまくって嫌な顔をされるアドカレおじさんです。
[Hamee](http://more.hamee.co.jp/)のエンジニア(一部デザイナー)が自分の興味あるテーマについて書いていきます。

## [Electron](https://electron.atom.io/)とは

wikiによると
>Electronは、GitHubが開発したオープンソースのソフトウェアフレームワークである。
ChromiumとNode.jsを使っており、HTML、CSS、JavaScriptのようなWeb技術で、macOS、Windows、Linuxに対応したデスクトップアプリケーションをつくることができる。
Atom、Slack、Visual Studio Codeなどで使用されている。

簡単に言うとフロントの技術でデスクトップアプリが作れるツール、という感じです
javascriptの知識があればある程度簡単にアプリが作れちゃいます！
![スクリーンショット 2017-11-06 16.56.47.png](https://qiita-image-store.s3.amazonaws.com/0/67471/a2d7c2e7-47e2-e8c2-efeb-548bdcbc814d.png)

## 背景と動機

普段困ったことがあるとChrome拡張でサクッと作って解決することが多いんですが、
「google calendarで管理しているミーティングルームに関して、毎回予定が空いてるか見るのが手間」
という課題を解決したいと思った時にデスクトップの常駐アプリがいいなぁと思って
「Mac デスクトップアプリ　作り方」で検索した結果Electronに行き着きました

### 今回作るもの

今回はElectronを使ってGoogle Calendarで管理しているミーティングルームの予定を見ることができるデスクトップを作りたいと思います。
ElectronとGoogle Calendar APIの知識が必要となります。

## 手順

筆者は普段Macで開発してるのでその前提で書きます

### 各種ツールのインストール

node.jsのインストール
`$ brew install node`

Electronのインストール
※ npmはnode.jsのパッケージ管理ツール（railsのbundler的な）
`$ npm -g install electron-prebuilt`

アプリケーション化するためのツールのインストール
`$ npm i electron-packager -g`

たった3コマンドで必要なツールが揃っちゃいます

### `package.json`

プロダクト用のディレクトリを切り
`$ npm init -y`
を実行すると`package.json`ができあがります
平たく言うとこのプロダクトのマニフェストファイル、設定ファイルみたいなものです
デフォルトではindex.jsが設定されますがmain.jsに変更しています
また、今回はgoogle calendar apiを使うので以下コマンドを実行し必要なライブラリを入れておきます
（ついでに個人的に扱いやすいのでjqueryを入れておく）

```bash
$ npm install googleapis --save
$ npm install google-auth-library --save
$ npm install --save jquery
```

```json:package.json
{
  "name": "meeting_room_app",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "Yamamoto Hiroya",
  "license": "MIT",
  "dependencies": {
    "google-auth-library": "^0.11.0",
    "googleapis": "^22.2.0",
    "jquery": "^3.2.1"
  }
}
```

`package-lock.json`と`node_modules`も生成されます
`node_modules`はライブラリのファイルなので`git ignore`推奨

### `main.js`と`index.html`

次に`main.js`と`index.html`を作成します
[こちらの記事](https://qiita.com/nyanchu/items/15d514d9b9f87e5c0a29)からお借りしました :bow: 

```js:main.js
'use strict';

// Electronのモジュール
const electron = require("electron");
// アプリケーションをコントロールするモジュール
const app = electron.app;
// ウィンドウを作成するモジュール
const BrowserWindow = electron.BrowserWindow;
// メインウィンドウはGCされないようにグローバル宣言
let mainWindow;

// 全てのウィンドウが閉じたら終了
app.on('window-all-closed', function() {
  if (process.platform != 'darwin') {
    app.quit();
  }
});

// Electronの初期化完了後に実行
app.on('ready', function() {
  // メイン画面の表示。ウィンドウの幅、高さを指定できる
  mainWindow = new BrowserWindow({width: 800, height: 600});
  mainWindow.loadURL('file://' + __dirname + '/index.html');

  // ウィンドウが閉じられたらアプリも終了
  mainWindow.on('closed', function() {
    mainWindow = null;
  });
});
```

```html:index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Sample</title>
</head>
<body>
  <p>Hello World</p>
</body>
</html>
```

### アプリケーションの実行

上記までできたらとりあえずHelloできるアプリを作成してみます
実行

```bash
$ electron main.js
```

Hello Worldの画面が出たら成功です

### アプリ化

```bash
$ cd ../
$ electron-packager meeting_room_app meeting_room_app --platform=darwin --arch=x64 --electronVersion=1.4.13
```

各引数は以下の通り

* ソースディレクトリ
* アプリ名
* 対象プラットフォーム（darwin=Mac）
* 対象アーキ（32bit or 64bit）
* Electronのバージョン（electron -vで確認）

これにより`meeting_room_app-darwin-x64`が生成されます
このディレクトリ内の`meeting_room_app.app`を実行するとアプリが起動できます
これにより配布が可能となります

## Google Calendar API

ここからカレンダーAPIを叩いていきます
API利用手順は以下の記事が参考になりました。
[GoogleカレンダーAPIとElectron](https://qiita.com/saisai/items/a0a17a9b0a0a7b6f3b5a)

基本的には以下の`quickstart.js`を元に作っていきます
https://developers.google.com/google-apps/calendar/quickstart/nodejs
これをmain.jsにコピペ

1. `Google API console`にアクセスし新規プロジェクトを作成する
1. `client_id`と`client_secret`を発行
1. `google calendar API`を利用許可
1. `clinet_secret.json`を画面からダウンロード
1. `client_secret.json`をアプリケーションルートに設置して`node main.js`を実行
1. URLが表示されるのでそれにアクセスし認証コードを取得
1. 認証コードをコンソールにペーストするとアクセストークンの情報が入ったjsonファイルを出力する
1. このファイルがあればAPIを実行できる

アクセストークンの入ったjsonファイルは`TOKEN_DIR`の場所に出力されます（大体ホームに生成されます）
このあたりの読み込みファイルのパスは都合に合わせて調整してください

### 構造

これでひと通り役者が出揃いましたので一旦treeの結果を載せておきます

```bash
.
├── calendar-nodejs-quickstart.json
├── client_secret.json
├── index.html
├── main.js
├── node_modules
├── package-lock.json
└── package.json
```

## 中身実装

ここまで来てしまえばあとは純粋な実装のみです
main.js内の`listEvents`と`index.html`を自分の作りたいものに変えていきましょう
以下に実装した部分のみピックアップして載せます

```js:main.js
function listEvents(auth) {
   // マルセイユとかは弊社の会議室名
   sendCalendarEvent(auth, 'カレンダーID', 'marseille');
   sendCalendarEvent(auth, 'カレンダーID', 'hongkong');
}

/**
 * @param {google.auth.OAuth2} auth An authorized OAuth2 client.
 * @param {string} 取得対象のカレンダーID
 * @param {string} 送信するイベント名
 */
function sendCalendarEvent(auth, calendar_id, event_name){
  var calendar = google.calendar('v3');

  // 取得範囲は+0~1分とする
  // NOTE: APIにminとmax同時刻で入れたら現在のイベントが取得できなかったため1分間の幅をもたせている
  var timeMax = new Date();
  var timeMin = new Date();
  timeMax.setMinutes(timeMax.getMinutes() + 1);
  timeMin.setMinutes(timeMin.getMinutes() + 0);

  calendar.events.list({
    auth: auth,
    calendarId: calendar_id,
    timeMax: timeMax.toISOString(),
    timeMin: timeMin.toISOString(),
    maxResults: 1,
    singleEvents: true,
    orderBy: 'startTime'
  }, function(err, response) {
    if (err) {
      console.log('The API returned an error: ' + err);
      return;
    }
    var events = response.items;
    if (events.length == 0) {
      //console.log(event_name+': イベントなし');
      mainWindow.webContents.send(event_name, 'イベントなし');
    } else {
      //console.log(event_name+': カレンダーのイベントの取得に成功');
      mainWindow.webContents.send(event_name, events[0]);
    }
  });
}

/**
 * ipc処理　非同期
 *
 * @param {async}
 */
const ipcMain = require('electron').ipcMain;
ipcMain.on('async', function( event, args ){
  　//シークレット取得
  fs.readFile(__dirname + '/client_secret.json', function processClientSecrets(err, content) {
      if (err) {
        console.log('Error loading client secret file: ' + err);
        return;
      }
      // Google Calendar API　の認証処理へ
      authorize(JSON.parse(content), listEvents);
    });
});
```

viewは全部だと長くなるのでscript部分のみ

```html:index.html
  <script>
    // レンダラプロセスでjQueryとElectron APIを使えるようにする
    // requireの代わりにnodeRequireを使用する
    // https://www.kabanoki.net/1156
    window.nodeRequire = require;
    delete window.require;
    delete window.exports;
    delete window.module;
  </script>
  <script type="text/javascript" src="http://ajax.microsoft.com/ajax/jquery/jquery-3.2.1.js"></script>
  <script>
    var ipcRenderer = nodeRequire( 'electron' ).ipcRenderer;

    // 非同期（カレンダー分）
    // 以下は弊社の会議室名
    var meeting_rooms = ['marseille', 'hongkong', 'sanfrancisco', 'liverpool', 'copenhagen', 'casablanca', 'mumbai', 'dubai'];

    $.each(meeting_rooms, function(index, value){
      // IPC通信（プロセス間通信）を行う
      ipcRenderer = nodeRequire( 'electron' ).ipcRenderer;
      ipcRenderer.on(value, function(event, calendar_event) {
        $('#' + value + '> p').empty();
        if(typeof(calendar_event.summary) !== 'undefined'){
          $('#' + value + '> p.event_name').append(calendar_event.summary);
        }
        if(typeof(calendar_event.organizer) !== 'undefined' && typeof(calendar_event.organizer.email) !== 'undefined'){
          var organizer = calendar_event.organizer.email.replace(/@.*$/, '');
          $('#' + value + '> p.organizer').append(organizer);
        }
        if(typeof(calendar_event.start) !== 'undefined' && typeof(calendar_event.end) !== 'undefined'){
          var start_hour = ('0'+new Date(calendar_event.start.dateTime).getHours()).slice(-2);
          var start_minu = ('0'+new Date(calendar_event.start.dateTime).getMinutes()).slice(-2);
          var end_hour   = ('0'+new Date(calendar_event.end.dateTime).getHours()).slice(-2);
          var end_minu   = ('0'+new Date(calendar_event.end.dateTime).getMinutes()).slice(-2);
          $('#' + value + '> p.time').append(start_hour+':'+start_minu+'~'+end_hour+':'+end_minu);
        }
      });
    });

    // カレンダーデータを10分間隔で定期的に取得
    function getData(){
      console.log('reload');
      ipcRenderer.send('async', 0);
    }
    setInterval(getData, 600000);
  </script>
```


## 画面イメージ

実際にはhtmlのDOM要素とcssを作成していますが割愛しました
githubにソースあげようと思いましたがclient_secretなどのアクセス情報をgitに入れてしまっていたため断念（◞‸◟）
今後はアクセス情報をgitに入れないようにしよう…反省

![スクリーンショット 2017-11-07 16.15.20.png](https://qiita-image-store.s3.amazonaws.com/0/67471/eb1cc824-e33b-e6de-f5ad-6bbf13824208.png)
(配置は実際の会議室の並びを元に作ってるのでこんな形になってます)

## ハマりポイント（ipc）

基本的な流れは参考記事や公式ドキュメント等でカバーできたのですが、ipcだけはちょっとハマりました
> プロセス間通信(IPC、英: interprocess communication)はコンピュータの動作において複数のプロセス間（の複数のスレッド間）でデータをやりとりするための仕組み。

Electron製のアプリにはJavaScript側のプロセス（メインプロセス）と、メインプロセスから立ち上げるBrowserWindowのプロセス（レンダラプロセス）の2種類があり、これらの間でデータのやりとりをするためには上記のipcの仕組みを使う必要がある
以下の記事が分かりやすかったです
[Electronでipcを使ってプロセス間通信を行う](https://qiita.com/Misumi_Rize/items/dde76dbf89abee13991c)

今回のアプリでは`index.html`から`setInterval`で一定間隔で`ipcRenderer.send('async', 0);`を呼んでいます
これが実行されるとレンダラプロセスからメインプロセスの`ipcMain.on('async', function(event, args ){`が呼ばれます
次にメインプロセスで任意の処理をしレンダラプロセスにデータを返すために`mainWindow.webContents.send(event_name, events[0]);`を呼びます
これによりレンダラプロセス側の`ipcRenderer.on(value, function(event, calendar_event) {`がキックされAPIで取得したデータをDOM要素に反映できる、という仕組みになっています

理解するのにやや時間がかかった… :sweat: 

## 参考

以下の記事が大変参考になりました
[30分で出来る、JavaScript (Electron) でデスクトップアプリを作って配布するまで](https://qiita.com/nyanchu/items/15d514d9b9f87e5c0a29)
[Electronでipcを使ってプロセス間通信を行う](https://qiita.com/Misumi_Rize/items/dde76dbf89abee13991c)
[GoogleカレンダーAPIとElectron](https://qiita.com/saisai/items/a0a17a9b0a0a7b6f3b5a)
[Node.js Quickstart](https://developers.google.com/google-apps/calendar/quickstart/nodejs)
[ElectronのRenderer Process（index.html）でrequire(‘electron’)がundefinedになる](https://www.kabanoki.net/1156)

## おわりに

いかがでしたでしょうか？
上記のipcの他にもjqueryとelectronの共存どうすんの？とかでちょっとハマりましたが参考記事等で回避できました。
こういうハマりが最近減ってきた気がするのでエンジニアとして少しは成長できてるのかな？と感じます。
`Electron`を使って手軽にデスクトップアプリが作れそうなイメージが湧いてくれると嬉しいです！


アドカレも3年目になりましたが毎年社員だけでカレンダーが1枚埋まるのはありがたいことです。
人も増えてきたし来年あたりは2枚埋めたい気持ちも…まだ分かりませんが :rolling_eyes: 
この記事でElectronと[Hamee](http://more.hamee.co.jp/)に少しでも興味を持ってくれたら嬉しいです！ｗ