JavaScript
Node.js
FeliCa
PaSori
Electron

【Electron】Felica連携デスクトップアプリケーションの開発

この記事では以下の内容を含んでいます

  • Node.jsのインストールとElectronのスタートアップ
  • Electronでffiをインストール
  • ffiでFelicalib.dllを起動し、PaSoRiからカードデータを取得する
  • 作成したElectronアプリケーションをasarで1つのファイルにまとめ、パッケージングする。

少々長いですがお付き合いいただけたら幸いです。

前文

この記事はNode.jsを利用して簡単にちょっと手の込んだデスクトップアプリケーションを利用しよう。という記事です。
手元の環境の都合上Windows10のみでの話となっております。
また、その他アプリケーションの環境などは記事投稿時点(2018/05/01)のものとなっているため、もしも新しいバージョンなどが出た場合はこの記事が古くなっている可能性もあるので注意してください。

Node.jsのインストールとElectronスタートアップ

Electron公式ホームページによると現在の安定板は

$ npm i -D electron@latest
# Electron   1.8.6
# Node       8.2.1
# Chromium   59.0.3071.115

とのことなので、まずはNode.jsの指定バージョンを入手します。

Node.jsのインストールと設定まで

Node.jsのリリース一覧ページで該当のバージョンを探してください。

拡張子が.msi 。またFelicalibは32ビット対応のものなのでx86のものをダウンロード。
この記事の場合はnode-v8.2.1-x86.msiになります。

ダウンロード後、インストール。全部画面に沿ってインストールを完了させてください。

その後、ターミナルで

$ node -v

とやり、正常にバージョンが表示されればインストール完了ではありません

なんとNode 8.2.1のWindowsインストール時に環境変数の設定を間違えて登録されていることがあるバグがあります。
そのため、次にパソコンの環境変数を確認してください。
具体的には「システム環境変数」の「Path」が以下のように登録されていたら、

C:\Program Files (x86)\nodejs\

以下のように修正を行ってください。

C:\Program Files (x86)\nodejs

末尾のスラッシュを一つ減らすだけです。

Electronをインストールする

プロジェクトを作成後、そのプロジェクト内で初期化を行います。

your-project> npm ini

色々聞かれるが、すべてエンターでも大丈夫。気になる人は後程 package.json を編集してください。
そしてElectronのインストール。

your-project> npm install electron --save-dev

その後、package.jsonを以下のように編集

package.json
{
  "name": "your-project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "electron ."
  },
  "author": "",
  "license": "ISC",
  "dependencies": {},
  "devDependencies": {
    "electron": "^1.8.6"
  }
}

index.jsとindex.htmlも記述します。

index.js
const electron = require('electron');
const {app, BrowserWindow} = electron;
const path = require('path');
const url = require('url');
const fs = require('fs');

let mainWindow;

//アプリケーション(基幹部)の起動が終了後動作する。
app.on('ready', () => {
    createWindow();
});

function createWindow() {
    mainWindow = new BrowserWindow({
        width: 400,
        height: 300,
        useContentSize: true,
        title: "test",

    });

    mainWindow.loadURL(url.format({
        pathname: path.join(__dirname, 'index.html'),
        protocol: 'file:',
        slashes: true
    }));

    //窓全部閉じたときnullにする
    mainWindow.on('closed', () => {
        mainWindow = null
    });
}
index.html
<!Doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>はろーわーるど</title>
</head>
<body>
はろーわーるど
</body>
</html>

コマンドを実行して、問題なく動作することを確認。

your-project> npm start

ffiのインストール

ffiって何よ

ffiとは、正確にはnode-ffiというdllファイルを動作させるためのNode.jsモジュールです。
詳しくは以下のサイトのほうが詳しいのでどうぞ
node-ffi/node-ffi

windows-build-toolsをインストールする。

windowsでffiをインストールするためのnode-gypを動作させるためには、pythonやVisualC++やらが必要になっている。
非常に面倒くさいのでそれらを一括で設定したりなんだりをやってくれるツールが存在しています。

windows-build-tools

ただし、これは管理権限を付与したPowerShellでないと実行できません。
PowerShellを起動し、以下のコマンドを入力

>> npm --add-python-to-path install --global windows-build-tools

この時、--add-python-to-pathを忘れるとpythonへパスが通らずに、やっぱりnode-gypが失敗することがあります。忘れないように注意。

インストールが終了後、プロジェクトに戻って改めてバージョンを確認してください。

your-project> python -V

きちんとバージョンが表示されれば成功です。

また、どうしてもだめな時は以下のページを参考にするといいでしょう。(勝手に参考にしています。ありがとうございます)
Windowsでnpm installしてnode-gypでつまずいた時対処方法

ffiをインストールする

ここまでくれば非常に簡単です。

your-project> npm install ffi

また、ffiで利用するために

your-project> npm install ref

あたりも入れておきましょう。

確認

それでは、index.jsでffirefを読み込んでみましょう。

index.js
const ffi = require('ffi');
const ref = require('ref');

そして実行。
もちろん失敗します。
以下のようなエラーが出るはずです。

App threw an error during load
Error: A dynamic link library (DLL) initialization routine failed.
\\?\C:\Users\your-project\node_modules\ref\build\Release\binding.node
    at process.module.(anonymous function) [as dlopen] (ELECTRON_ASAR.js:172:20)
    at Object.Module._extensions..node (module.js:598:18)
    at Object.module.(anonymous function) [as .node] (ELECTRON_ASAR.js:172:20)
    at Module.load (module.js:503:32)
    at tryModuleLoad (module.js:466:12)
    at Function.Module._load (module.js:458:3)
    at Module.require (module.js:513:17)
    at require (internal/module.js:11:18)
    at bindings (C:\Users\tanabe\PhpstormProjects\felicatest\node_modules\bindings\bindings.js:76:44)
    at Object.<anonymous> (C:\Users\tanabe\PhpstormProjects\felicatest\node_modules\ref\lib\ref.js:5:47)

そもそもElectronの使うV8は公式のNodeとは違うのでビルドに失敗するとのことです。
面倒ですね。でも動くようにしましょう。

※この時、アプリケーションが中途半端に実行状態にある状態で落ちている場合が多いのでCtrl+Cとか、タスクランナーとかできちんとアプリケーションを落としておくことをおすすめします。

ffiを動くようにする。

Electronでffiを利用できるように、ビルド用のモジュールをインストールします。

your-project> npm install --save-dev electron-rebuild

インストール後、リビルドを実行します。

your-project> .\node_modules\.bin\electron-rebuild.cmd

ディレクトリを示すスラッシュはただのスラッシュでもバックスラッシュでもどちらでも問題ありません。ただ、バックスラッシュを利用するとWindows側できちんとディレクトリを認識してtabキーによる入力補完が使えるため非常に便利です。

もう一度確認

それではもう一度ffirefを読み込んだスクリプトのあるelectronを実行しましょう。
動いたら成功です。

ffiでFelicalib.dllを起動し、PaSoRiからカードデータを取得する

felicalib公式サイト

FeliCaポートソフトウェアをインストールする

felicalib.dllはPaSoRi前提の仕組みのため、それを動作させるための前提アプリケーションと物理的に読み込むものが必要とされます。
SONYのPaSoRi公式ページから前提アプリケーションをダウンロードし、インストールしてください。
ついでにPaSoRi本体は何らかの手段で手に入れてください。Amazonとか、近所の電気屋とかで。

インストーラーにそってインストールを行ってください。
以降の記述はパソコンにPaSoRiがささっている前提で話しています。

ffiでfelicalib.dllを読み込む

先ほどのfelicalib公式サイトからfelicalib.dllをダウンロードし、プロジェクトディレクトリ内に設置してください。
後程asarで圧縮する予定のため、ルートディレクトリに置いておくと問題が少なく済みます。

どうしてもルートディレクトリ以外に設置したい場合は、がんばってasarで圧縮しても問題なく動作する方法を探すか、そもそも圧縮を諦めたほうが良いかもしれません。問題なく動作する方法を見つけたら是非とも私まで教えてください……。

それでは、実際にfelicalibをffiで読み込みます・・・が、専用処理が多いのでライブラリに分割します。

FelicaAccesser.js
const ref = require("ref");
const ffi = require("ffi");
const path = require("path");

/*
 * Felicalib.dllと連携し、PaSoRiを利用してIDmを読み取る仕組み
 */
let FelicaAccessar = {
    felicalib: null,
    pasoriPtr: null,
    felicap: null,
    isTouching: false,
    errorFlag: false, //エラー検出フラグ。検出した1回だけcallbackを出す
    /**
     * FelicaAccesserを読み出し時、必ず一回動作させること。
     * 念のため二回目以降はこのモデル内の変数を確認し、実行自体を行わないようにしている。
     * @constructor
     */
    Felica: () => {
        //felicalib.dllを利用できる形にコンバート
        //ポインタ型は ref.refType()で厳密に作成することも出来るが、別にchar*でもpointerでも良い。
        //(どう宣言しようとも、どうせNodejs内での扱いはBuffer型になる)
        //また、それぞれの利用する関数は以下のように宣言している。
        //関数名: [戻り値, [引数1,引数2,...]]
        if (FelicaAccessar.felicalib === null) {
            let intPtr = ref.refType("int");
            let stringPtr = ref.refType(ref.types.CString);

            try {
                let felicalibPath = path.join('./','felicalib');
                console.log(felicalibPath);
                FelicaAccessar.felicalib = ffi.Library(felicalibPath, {
                    pasori_open: [intPtr, [stringPtr]],
                    pasori_close: ["void", [intPtr]],
                    pasori_init: ["int", [intPtr]],
                    felica_polling: [intPtr, [intPtr, "uint16", "uint8", "uint8"]],
                    felica_free: ["void", [intPtr]],
                    felica_getidm: ["void", [intPtr, "char*"]],
                    felica_getpmm: ["void", [intPtr, "char*"]],
                    felica_read_without_encryption02: ["int", [intPtr, "int", "int", "uint8", "uint8"]]
                });
            } catch (e) {
                throw "ライブラリの読み込みにエラーが発生しています。";
            }

        }

        if (FelicaAccessar.pasoriPtr === null) {
            let pasoriPtr = FelicaAccessar.felicalib.pasori_open(null);

            if (pasoriPtr.length === 0) {
                throw "felicalib.dll ファイルを開くことが出来ません。";
            }
            if (FelicaAccessar.felicalib.pasori_init(pasoriPtr) !== 0) {
                throw "PaSoRi に接続できません。";
            }

            FelicaAccessar.pasoriPtr = pasoriPtr;
        }
    },
    /**
     * PaSoRiとの接続を終了する。
     * もしもそもそも接続をしていない(ポインタを変数に持っていない)ときはとりあえず何もしない。
     */
    Close: () => {
        if (FelicaAccessar.pasoriPtr !== null) {
            FelicaAccessar.felicalib.pasori_close(FelicaAccessar.pasoriPtr);
            FelicaAccessar.pasoriPtr = null;
        }
    },
    /**
     * タッチされているカードの情報を取得する。
     * カード情報はFelicaAccessar.felicapに入っている。また、取得成功\失敗情報はbool値で返している。
     * @return {boolean}
     */
    Polling: () => {
        FelicaAccessar.felicalib.felica_free(FelicaAccessar.felicap);
        FelicaAccessar.felicap = FelicaAccessar.felicalib.felica_polling(FelicaAccessar.pasoriPtr, "0xffff", 0, 0);
        if (FelicaAccessar.felicap.length === 0) {
            return false;
        } else {
            return true;
        }
    },
    /**
     * Pollingで取得したカード情報からidmデータを取得する。
     * 正確に言うと、現在 FelicaAccsessar.felicap に登録されているBufferをFelicaカードだと仮定して、idm情報を取得する。
     * もしも FelicaAccesser.felicap がFelicaカードのポインタではない時、catchできないエラーが発生してアプリケーションが止まる。注意。
     * @return {string}
     */
    IDm: () => {
        if (FelicaAccessar.felicap.length === 0) {
            throw "タッチされていません";
        }

        //Pollingで取得したカード情報からidm情報を取得する。
        //この時点で取得できるのは10進数で表記された8個の整数。
        let byte = new Uint8Array(8);
        FelicaAccessar.felicalib.felica_getidm(FelicaAccessar.felicap, byte);

        //16進数表記の文字列に変更する。
        let id = "";
        for (let i = 0; i < byte.length; i++) {
            let tmp = byte[i].toString(16);

            id += ("00" + tmp).slice(-2);
        }

        return id;
    },
    /**
     * Polling()とIDm()の関数をいい感じに利用できるようにした部分。
     * これを実行すると、1秒に1回カード情報を取得する処理を実行する。
     * カード情報を受け取った時にこの取得処理自体をストップし、コールバック関数を実行している。
     * システム内でカード情報を利用した処理の終了後、再びこの関数を実行すること。
     *
     * @param {function} callback 詳細は renderer.js の onTouchFelica() 参照
     * 第一引数に現在の状態。第二引数にidmまたはエラー文字列。
     * 0=正常(タッチ発生) 1=エラー発生 2=エラー続行 3=エラー解消(正常続行)
     * 0かつ3のときは0の処理を優先する。
     * 2の時は普通にpolling(再接続)をするためコールバックしない。
     */
    loopPollingPaSoRi: (callback) => {
        let Polling = FelicaAccessar.Polling;
        let IDm = FelicaAccessar.IDm;

        let timer = setInterval(() => {
            try {
                FelicaAccessar.Felica();

                let isReading = Polling();

                //タッチ中フラグが立っている&タッチしているとき、何もしない
                if (FelicaAccessar.isTouching && isReading) {
                    //no-op
                }
                //タッチ中フラグが立っていない&タッチしている時、タッチ処理を行う
                else if (FelicaAccessar.isTouching === false && isReading) {
                    let idm = IDm();
                    clearInterval(timer);
                    FelicaAccessar.isTouching = true;

                    if (callback !== undefined) {
                        callback(0, idm);
                    }
                }
                //タッチ中フラグが立っている&タッチしていないとき、タッチ中フラグを折る
                else if (FelicaAccessar.isTouching && isReading === false) {
                    FelicaAccessar.isTouching = false;
                } else {
                    //no-op
                }

                FelicaAccessar.Close();

                //問題なく動作完了したためerrorFlagが立っていれば折る。
                if (FelicaAccessar.errorFlag === true) {
                    FelicaAccessar.errorFlag = false;
                    clearInterval(timer);
                    callback(3, "PaSoRiを確認しました。");
                }
            } catch (e) {
                if (FelicaAccessar.errorFlag === false) {
                    clearInterval(timer);
                    FelicaAccessar.errorFlag = true;
                    callback(1, e);
                }
            }
        }, 1000);
    },
};
module.exports = FelicaAccessar;

またindex.jsを以下のように更新します。

index.js
const electron = require('electron');
const {app, BrowserWindow, dialog} = electron;
const path = require('path');
const url = require('url');
const FelicaAccesser = require('./FelicaAccesser');

let mainWindow;

//アプリケーション(基幹部)の起動が終了後動作する。
app.on('ready', () => {
    createWindow();
    FelicaAccesser.Felica();
    FelicaAccesser.loopPollingPaSoRi(onTouchFelica);
});

function createWindow() {
    // Create the browser window.
    mainWindow = new BrowserWindow({
        width: 400,
        height: 300,
        useContentSize: true,
        title: "test",

    });

    mainWindow.loadURL(url.format({
        pathname: path.join(__dirname, 'index.html'),
        protocol: 'file:',
        slashes: true
    }));

    mainWindow.webContents.openDevTools();

    //窓全部閉じたときnullにする
    mainWindow.on('closed', () => {
        mainWindow = null
    });
}

function onTouchFelica(status, idm) {
    //タッチまたはFelicaの接続が切れた時処理時実行
    if (status === 0) {
        dialog.showMessageBox({
            type: "info",
            title: "touch card",
            message: idm
        });
    } else if (status === 3) {
        dialog.showMessageBox({
            type: "info",
            title: "restart PaSoRi",
            message: "",
        });
    } else if (status === 1) {
        dialog.showMessageBox({
            type: "error",
            title: "error no PaSoRi",
            message: "",
        });
    } else {
        //何もしない
    }

    //処理終了。タッチイベントを再開する。
    FelicaAccesser.loopPollingPaSoRi(onTouchFelica);
}

実行

それではnpm startで実行し、問題なくアプリケーションが動作することを確認してください。
Felicaをかざしたらidm情報がダイアログで表示されれば問題ありません。
また、一応PaSoRi側に問題が発生した時もいろいろ出るようになってます。

asarでまとめてパッケージングする

asarとは?

以下のページが非常に詳しいのでおすすめします。
asarを使ってみる

それではasarを入手…してもいいのですが、今回はもう少し簡単な手段でやります。

electron-packagerをインストールする

electron-userland/electron-packager(Github)とは、Electronアプリケーションをいい感じにパッケージングするための仕組みです。
以下のようにインストールを行ってください。

your-project> npm install electron-packager --save-dev

細かい説明についてはgithubの説明を直接見てもらうとして、このパッケージャーはオプションでasar圧縮を指定することができます。
以下のようにして圧縮することができます。

your-project> electron-packager . test ––platform=darwin,win32 ––arch=x64 ––version=0.0.1 --asar

アイコンなどの設定をしたかったら、オプションを増やしてください。

パッケージング後、プロジェクトディレクトリ内にtest-win32-is32のような形式でディレクトリが作られているかと思われるので、それを開き、中にあるtest.exeを実行してみてください。エラーが出ます

原因として考えられるのは「.dllファイルは直接パソコンに受け渡す必要があるから」らしいのですが……正確なところは知識がないので不明です。
しかしtest.exeのあるディレクトリに直接felicalib.dllファイルを放り込むと問題なく動作するので、これでとりあえず良いでしょう。

先ほど開いたtest.exeを一度閉じて再び開きなおすと、エラー無く起動します。

おわり

これでデスクトップアプリケーションが完成しました。あとはソースコードを弄るなりもう少し見た目を良くするなり、きちんとパッケージングするなり頑張ってください。

おつかれさまでした。

気になる点

  • 開発中のディレクトリと、完成後のパッケージングされたディレクトリ、二重でfelicalib.dllが入ってしまっている。
  • すべてプロジェクトディレクトリ内で完結させてしまったので、不必要なファイルが大量に含まれている。
  • test.exeのあるディレクトリ内に大量の.dllファイルが入ってしまっていてあまりきれいな状態とは言えない。