株式会社スマレジの開発部でスマレジのサーバサイドを作っています

Electronを使って、Node.jsバッチをクライアントアプリ化する。

こんばんは!株式会社スマレジ、開発部のmasaです。 また投稿が開いてしまい申し訳ないです。。。汗

今回は前回のブログで紹介したツールをデスクトップアプリ化したので、そのお話をしたいと思います。 前回の投稿は↓から。

masa2019.hatenablog.com

electronを使って、Node.jsのバッチツールをGUIアプリ化する。

前回のブログで紹介した、redmine apiと smaregi platform apiを使用して、タイムカードの日報をつける仕組みですが、 あれはコマンドライン実行のツールです。

現在、このツールを使ってメンバーの皆様に日報をつけていただいているのですが、 サンドボックス環境から本番に転記しているので完全自動化ができていないんです。

そこで本番運用を考えた時に問題になったのが、「アプリアクセストークン」です。 今回、smaregi platform api側ではアプリアクセストークンを利用していました。

アプリアクセストークンの取得の仕方は↓

developers.smaregi.dev

このアクセストークンはユーザの権限が反映されないため、他の人の日報も編集できてしまうという問題がありました。

そこで、ユーザの権限が反映される、ユーザアクセストークンを使用するように変更をする必要が出てきました。

ユーザアクセストーク

ユーザアクセストークンは「だれがAPIを使っているか」を判断する必要があるので、

  1. スマレジのログイン画面にリダイレクト
  2. 承認を行う
  3. リダイレクト後、アプリに処理が戻ってくる(この時アクセストークンを取るのに必要な認可コードなども取れる)

という流れになるので、3でコマンドラインツールに処理を戻してあげる必要が出てきました。 しかし、実行しているのはローカル環境です。リダイレクト先に指定することも、どこかから投げ直すこともできない状態。。。

そのため、クライアントアプリのディープリンク機能を使ってリクエストを渡すことを考えたのです。 ディープリンクについては↓

www.adjust.com

Electron

今回は既存資産にNode.jsのバッチがあるので、できればその資産をそのまま流用したい。 そこで、Node.jsでも動作するクライアントアプリのフレームワークがないか調べてみたところ、Electronに行きつきました。

www.electronjs.org

Node.jsで動いて、フロントにhtmlも使える・・・!ちょうどいいなと思い採用してみました。

Electronで作った日報生成ツール

出来上がったのが上の画像のアプリです。 追加したコードの一部を下に載せます。

まずはフロントのhtml(CSSは割愛します)

<html>
<head>
    <meta charset="UTF-8"/>
    <!-- https://developer.mozilla.org/ja/docs/Web/HTTP/CSP -->
    <title>日報生成ツール</title>
    <style>
        (中略)
    </style>
    <script src="https://cdn.jsdelivr.net/npm/crypto-js@4.0.0/crypto-js.js"></script>
</head>

<body>
<script type="text/javascript" src="../frontend/click.js"></script>
<div id="code_verifier"></div>
<div id="access_token"></div>
<form id="createDailyReportForm">
    <div class="inputWithIcon inputIconBg">
        <input type="text" placeholder="Redmine User ID" id="redmineUserId">
    </div>

    <div class="inputWithIcon inputIconBg">
        <input type="text" placeholder="Timecard User ID" id="timecardUserId">
    </div>

    <div class="inputWithIcon inputIconBg">
        <input type="text" placeholder="yyyymmdd" id="ymd">
    </div>

    <button type="button" class="btn btn-primary" onclick="execute()">日報を生成</button>
</form>
</body>
</html>

ここは特にいうことはないと思います。

で、ボタンに紐づいているイベントが書いてあるフロントJS。

async function execute() {
    let ymd = document.getElementById('ymd').value;
    let redmineUserId = document.getElementById('redmineUserId').value;
    let timecardUserId = document.getElementById('timecardUserId').value;
    if (ymd.length !== 8) {
        alert("yyyymmddが不正です。");
        return;
    }
    if (redmineUserId.length < 1) {
        alert("redmineUserIdは必須です。");
        return;
    }
    if (timecardUserId.length < 1) {
        alert("timecardUserIdは必須です。");
        return;
    }
    const accessToken = document.getElementById('access_token').innerText;
    window.electronAPI.execute([ymd, timecardUserId, redmineUserId, accessToken]);

}

// (ハッシュ生成のメソッドは省略)

window.electronAPI.auth((event, value) => {
    console.log(event);
    console.log(value);
    value.codeVerifier = document.getElementById('code_verifier').innerText;
    window.electronAPI.getRefreshToken(value);
})

window.electronAPI.openForm((event, value) => {
    console.log(value);
    document.getElementById('access_token').innerText = value["access_token"];
    document.getElementById('createDailyReportForm').style.display = 'inherit';
    document.getElementById('redmineUserId').value = value["redmine-user-id"];
    document.getElementById('timecardUserId').value = value["timecard-user-id"];

})

window.electronAPI.getAuthorizationCode(() => {
    const codeVerifier = generateCodeVerifier();
    document.getElementById('code_verifier').innerText = codeVerifier;
    const codeChallenge = generateCodeChallenge(codeVerifier); // 中身は割愛
    window.location.href = "https://id.smaregi.dev/authorize?response_type=code&scope=openid offline_access timecard.shifts:read timecard.daily-reports:write&state=12345&redirect_uri=http://(リダイレクト先のURL)&client_id=(クライアントID)&code_challenge_method=S256&code_challenge=" + codeChallenge;
})

window.onload = function() {
    window.electronAPI.checkAuth();
}

Electronで特徴的なのが、 window.electronAPI.checkAuth();この表記。このwindowオブジェクトにAPIを注入することができるんです。

で、それをやっているのがpreloadで動くJS。↓

const { contextBridge, ipcRenderer } = require('electron')
const axios = require("axios");
const {log} = require("electron-log");

contextBridge.exposeInMainWorld('electronAPI', {
    checkAuth: () => ipcRenderer.send('checkAuth'),
    getAuthorizationCode: (callback) => ipcRenderer.on('getAuthorizationCode', callback),
    auth: (callback) => ipcRenderer.on('auth', callback),
    getRefreshToken: (value) => ipcRenderer.send('getRefreshToken', value),
    openForm: (callback) => ipcRenderer.on('openForm', callback),
    execute: (value) => ipcRenderer.send('execute', value),
})

contextBridge.exposeInMainWorld()の第一引数で注入するAPIオブジェクト名, 2つ目にAPIの中身を書きます。 この時、ipcRenderer.sendをしているのが、アプリ側のAPIをコールしていて、ipcRenderer.onでコールしているのが、フロントJS側のAPIになります。

最後に、アプリ側に追加したAPIのJS

// アプリケーション作成用のモジュールを読み込み
const { app, BrowserWindow, ipcMain, shell, dialog, ipcRenderer} = require('electron')
const path = require("path");
const ipc = require('electron').ipcMain
const {execute} = require("./index");
const {getStringFromDate} = require("./util");
const log = require('electron-log');
const axios = require("axios");
const Store = require('electron-store');
const store = new Store();

const apiRequestHeaders = {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Authorization': 'Basic 認証キー'
}


let mainWindow;

if (process.defaultApp) {
    if (process.argv.length >= 2) {
        app.setAsDefaultProtocolClient('electron-fiddle', process.execPath, [path.resolve(process.argv[1])])
    }
} else {
    app.setAsDefaultProtocolClient('electron-fiddle')
}

const gotTheLock = app.requestSingleInstanceLock()

if (!gotTheLock) {
    app.quit()
} else {
    app.on('second-instance', (event, commandLine, workingDirectory) => {
        // Someone tried to run a second instance, we should focus our window.
        if (mainWindow) {
            if (mainWindow.isMinimized()) mainWindow.restore()
            mainWindow.focus()
        }
    })

    // Create mainWindow, load the rest of the app, etc...
    app.whenReady().then(() => {
        createWindow()
    })
}

function createWindow () {
    // Create the browser window.
    mainWindow = new BrowserWindow({
        width: 400,
        height: 240,
        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
            nodeIntegration: true,
            contextIsolation: true
        }
    })

    // window event
    mainWindow.webContents.on("will-navigate", (event,url) => {
        if( url.match(/^http/)) {
            event.preventDefault()
            shell.openExternal(url).then(r => {})
        }
    })

    // デベロッパーツールの起動
    // mainWindow.webContents.openDevTools();
    mainWindow.webContents.send('login');
    mainWindow.loadFile('html/index.html');
}

app.on('open-url', (event, urlStr) => {
    if (typeof mainWindow === "undefined") {
        dialog.showErrorBox('エラー', `日報ツールが起動していません。`);
        return;
    }
    const urlArr = urlStr.split("?");
    if (urlArr.length < 1) {
        dialog.showErrorBox('不正な呼び出し', `不正な呼び出しが発生しました。`);
        return;
    }
    const queryParamsStr = urlArr[1];
    const queryParamsArr = queryParamsStr.split("&");
    const queryParams = {};
    let wkArr = "";
    for (let i = 0; i < queryParamsArr.length; i++) {
        wkArr = queryParamsArr[i].split("=");
        if (wkArr.length < 1) {
            continue;
        }
        queryParams[wkArr[0]] = wkArr[1];
    }
    mainWindow.webContents.send('auth', queryParams);
})

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') app.quit()
})

// リフレッシュトークンの取得
const getRefreshToken = function(queryParams) {
    log.info(queryParams);

    const requestBody = new URLSearchParams();
    requestBody.append('grant_type', 'authorization_code');
    requestBody.append('code', queryParams.code);
    requestBody.append('redirect_uri', 'http://160.16.110.61/redirect.php');
    requestBody.append('code_verifier', queryParams.codeVerifier);

    axios({
        method: "POST",
        url: 'https://id.smaregi.dev/authorize/token',
        headers: apiRequestHeaders,
        data: requestBody
    }).then(response => {
        log.info('body:', response.data);
        store.set('access-token', response.data.access_token);
        store.set('refresh-token', response.data.refresh_token);
        const today = new Date();
        store.set('last-refresh-date-time', getStringFromDate(today));
        store.set('last-authorization-date-time', getStringFromDate(today));
        let redmineUserId = store.get('redmine-user-id', "");
        let timecardUserId = store.get('timecard-user-id', "");

        response.data['redmine-user-id'] = redmineUserId;
        response.data['timecard-user-id'] = timecardUserId;
        mainWindow.webContents.send('openForm', response.data);
    });
}

/** アクセストークンのリフレッシュ処理 **/
const refreshAccessToken = async function(refreshToken) {
    const requestBody = new URLSearchParams();
    requestBody.append('grant_type', 'refresh_token');
    requestBody.append('refresh_token', refreshToken);

    await axios({
        method: "POST",
        url: 'https://id.smaregi.dev/authorize/token',
        headers: apiRequestHeaders,
        data: requestBody
    }).then(response => {
        log.info('body:', response.data);
        store.set('access-token', response.data.access_token);
        store.set('refresh-token', response.data.refresh_token);
        const today = new Date();
        store.set('last-refresh-date-time', getStringFromDate(today));
        store.set('last-authorization-date-time', getStringFromDate(today));
    });
}


ipc.on('checkAuth', async function(event) {
    let authFlag = false;
    let refreshFlag = false;
    let accessToken = store.get('access-token', "");
    if (accessToken.length < 1) {
        authFlag = true;
    }
    const refreshToken = store.get('refresh-token', "");
    if (refreshToken.length < 1) {
        authFlag = true;
    }

    const lastAuthorizationDateTimeStr = store.get('last-authorization-date-time', "");
    let lastAuthorizationDateTime = new Date('2020-01-01 00:00:00');
    if (lastAuthorizationDateTimeStr.length > 0) {
        lastAuthorizationDateTime = new Date(lastAuthorizationDateTimeStr);
    } else {
        authFlag = true;
    }
    let today = new Date();
    log.info(today);
    log.info(lastAuthorizationDateTime);
    log.info((today.getTime() - lastAuthorizationDateTime.getTime()) / 1000);
    if (today.getTime() - lastAuthorizationDateTime.getTime() > 1000 * 60 * 59) {
        refreshFlag = true;
    }

//(中略)

ipc.on('getRefreshToken', function(event, args) {
    getRefreshToken(args);
})

ipc.on('execute', async function (event, args) {
    log.info(args);
    store.set('redmine-user-id', args[2]);
    store.set('timecard-user-id', args[1]);
    await execute(args[0], args[1], args[2], args[3]);
})

長いので主要なところを掻い摘むと、

  • app.on('open-url', (event, urlStr) => {... これがディープリンクで呼び出された時に走る処理
  • ipc.on() がpreloadのJSで定義されたAPIの実体
  • mainWindow.webContents.send(); がpreloadを通じて、フロント側のAPIを呼び出す処理

という感じです。(社内ツール用でスピード重視なのでこうなっていますが、本当はAPIの処理とかは別ファイルに分離しないといけないです。)

リフレッシュトークンの話とかもあるんですが、その辺りは長くなるので、また次回に。

書いてみて

イベントハンドリングが少し独特でしたが、ほとんどWebアプリを作るのと変わらない感覚でデスクトップアプリが作れるのは、 面白いし便利!というのが感想です。今後も使っていこー。