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

はじめに

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

Electronとは

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

背景と動機

普段困ったことがあると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を入れておく)

$ npm install googleapis --save
$ npm install google-auth-library --save
$ npm install --save jquery
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.jsonnode_modulesも生成されます
node_modulesはライブラリのファイルなのでgit ignore推奨

main.jsindex.html

次にmain.jsindex.htmlを作成します
こちらの記事からお借りしました :bow:

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;
  });
});
index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Sample</title>
</head>
<body>
  <p>Hello World</p>
</body>
</html>

アプリケーションの実行

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

$ electron main.js

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

アプリ化

$ 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

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

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

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

構造

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

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

中身実装

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

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部分のみ

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
(配置は実際の会議室の並びを元に作ってるのでこんな形になってます)

ハマりポイント(ipc)

基本的な流れは参考記事や公式ドキュメント等でカバーできたのですが、ipcだけはちょっとハマりました

プロセス間通信(IPC、英: interprocess communication)はコンピュータの動作において複数のプロセス間(の複数のスレッド間)でデータをやりとりするための仕組み。

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

今回のアプリでは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) でデスクトップアプリを作って配布するまで
Electronでipcを使ってプロセス間通信を行う
GoogleカレンダーAPIとElectron
Node.js Quickstart
ElectronのRenderer Process(index.html)でrequire(‘electron’)がundefinedになる

おわりに

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

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

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.