electronでアップデータを配布する簡単な方法
electronにはautoUpdaterと呼ばれる仕組みが用意されていますが、非常に複雑な上に場合によってはコードサイニングが必要である(その割には、やることがアップデートだけ・・・)という事で、社内で使うにはちょっと面倒。
また、Mac向けとWindows向けで手法が異なる。という事で、外部向けではないのであれば、面倒な手法など使わずこれまでのようなクラシックな手法でアップデートできたら良いな、と考えAccessのアプリのアップデータ配布と同じような手法を装備してみた。
今回準備するもの
- アップデータ本体であるexeファイルやdmgファイル
- アップデート情報の入ったファイルもしくは、MySQLサーバのテーブルとデータ
- exeファイルを置けるファイルサーバやWebサーバ
今回のケースでは、MySQLサーバの特定のテーブルにある「バージョン情報」と、自身のバージョン情報を照合し、新しいバージョンがある場合には、対象のファイルをダウンロード、自動実行の後、再びアプリを起動するという手法を取っています。MySQLがなくともJSONやXMLといったファイルをダウンロードして、バージョン情報を取得する方法でも良いでしょう。
今回、Windows向けという事で、Exepress6にてインストーラを作成しています。インストール後にアプリ本体のexeを自動実行するように設定を施してありますので、アプリの自動起動自体は設定のみでOKです。
ソースコード
サーバー側コード
サーバ側はNode.jsでMySQLへアクセスし返すように組んでいます。electron側から直接接続でも良いのですが、今回はWeb APIな仕組みで運用しています。別途インストールしてるモジュールは、expressとpromise-mysqlの2つです。
'user strict'; // expressフレームワーク const express = require('express'); const app = express(); const bodyParser = require('body-parser'); var fs = require('fs'); //MySQL接続用モジュール読み込み const mysql = require('promise-mysql'); //serverのパス const server = "localhost"; //現在最新のアプリのバージョン情報を取得する app.post('/api/v1/appman', (req, res) =>{ //リクエストパラメータを取得する var para = req.body; //パスワードを取得する var pass = para.pass; //ユーザIDを取得する var userid = para.uid; appVersionsql([userid,pass],function (ret){ //値をリターンする res.json(ret); return; }); }); //appVerの情報を取得して返す function appVersionsql(args,callback){ //リクエストパラメータを取り出す var uid = args[0]; var pass = args[1]; var connection; var retman = {}; var result = "" //MySQLに接続してデータを取得する mysql.createConnection({ host: server, port: 3306, user: uid, password: pass, database: "database名" }).then(function(conn){ //クエリの実行 connection = conn; var result = connection.query("SELECT * FROM setting where ID = 2;", function (err, rows, fields) { //エラーが発生した場合 if (err) { console.log("接続エラー"); retman.status = "ERR"; retman.error = err; callback(retman); connection.end(); return; } retman.status = "OK"; retman.version = rows[0].value; retman.exeurl = rows[0].text; callback(retman); connection.end(); return; }); }).catch(function(error){ if (connection && connection.end) connection.end(); //logs out the error retman.status = "ERR"; retman.error = "接続エラーですよ。パスワードが違うとかサーバアドレス間違ってるとか、ありませんか?"; callback(retman); return; }); }
- 接続情報であるUser IDとPASSワードはクライアント側から送ります。
- settingテーブルは、ID, value, textの3列だけです。valueにはバージョン情報、textにはexeファイルまでのURLを入れてあります。
- settingテーブルのIDが2のレコードがアップデート情報のレコードという事にしてあります。
- クライアント側からは、POST通信でAPIのやり取りを行っています。
クライアント側コード
クライアント側であるelectronのindex.jsには、起動時にバージョン情報を取得しに行くコードを用意します。node-cronやイベントループの仕組みを使ったりして、常時アップデート情報を監視する方法も良いでしょう。
electron側で今回利用する追加モジュールは、request、electron-store、keytarになります。他にも標準でいくつかのモジュールを利用しています。
'use strict'; //標準モジュールの宣言 const electron = require('electron'); const { app, dialog } = require('electron'); var fs = require('fs'); //HTTPリクエスト用モジュールの読み込み var request = require('request'); var verapiurl = "http://" + store.get("server") + ":1259/api/v1/appman"; //追加モジュールの宣言 const Store = require('electron-store'); const store = new Store(); const keytar = require('keytar'); //exe実行用 const { spawn } = require('child_process'); //自身のバージョンを取得 var pVer = require('./package.json').version; //初期化 app.on('ready', () => { //アップデートバージョンを取得 if(store.get("server") == undefined){ console.log("接続情報なし"); }else{ //ヘッダー情報 var headers = { 'Content-Type':'application/json', } //サービス名を構築する var servicename = "zaseki_" + store.get("id"); var secret = keytar.getPassword(servicename,store.get("id")); secret.then((result) => { //パスワードを取得する var passman = result; //パラメータ var param = { uid : store.get("id"), pass : passman } //オプション設定 var options = { url: verapiurl, method: 'POST', body: JSON.stringify(param), headers: headers, } //httpリクエスト request(options, function (error, response, body) { //エラー発生時 if (error) { return; } //ステータス判定 var dat = JSON.parse(body); if(dat.status == "OK"){ //サーバ側バージョン情報を取得 var sVer = dat.version; var exeurl = dat.exeurl; //バージョン比較 if(parseFloat(sVer) > parseFloat(pVer)){ var options = { type: 'info', buttons: ['OK', 'Cancel'], title: 'バージョンアップ', message: '新しい版の座席表がリリースされています。アップデートしますか?', detail: '最新版をダウンロードして、アップデートをし自動で再起動します。' }; var resdia = dialog.showMessageBox(null, options); //条件分岐 if(resdia == 1){ //とくに何もしない }else{ //アップデータをダウンロードして実行する execupdate(exeurl); } } }else{ //エラー発生時 } return; }); }); } }); //アップデータをダウンロードして実行 function execupdate(baseurl){ //ファイルダウンロードリクエスト request(baseurl, { encoding: 'binary' }, (error, response, body) => { if (!error) { //ファイル名の指定 var filename = "/hogehoge.exe"; //デスクトップのパスを指定 var dir_home = process.env[process.platform == "win32" ? "USERPROFILE" : "HOME"]; var dir_desktop = require("path").join(dir_home, "Desktop"); //保存場所を指定 var options = { defaultPath: dir_desktop + filename, title: 'Download File', filters: [ {name: 'インストーラファイル', extensions: ['exe']}, ] } //ダイアログを表示 dialog.showSaveDialog(null, options, fileName => { //フルパスを取得する var fullpath = fileName; if(fullpath==undefined){ //キャンセルされたので、メッセージだけ出して終了 var options ={ type:'info', title:"ダウンロードキャンセル", button:['OK'], message:'アップデートはキャンセルされました。', detail:'再度アプデする場合は、アプリを起動しなおす必要があります。' } //表示する dialog.showMessageBox(null,options); }else{ //ファイルを書き出す fs.writeFile(fullpath, body, 'binary', (err) => { //コマンドラインを構築 runCommand(fullpath); //閉じる process.exit(0); }); } }) }else{ //ファイルダウンロード失敗時 console.log(error); } }); } /** コマンドを外部プロセスとして実行 */ function runCommand(commandline) { const parts = commandline.split(" "); const cmd = parts[0]; const args = parts.splice(1); // バックグラウンドで実行: // メインプロセスが終了しても外部プロセスとして動作します。 const child = spawn(cmd, args, { stdio: 'ignore', detached: true, env: process.env, }); child.unref(); // メインプロセスから切り離す }
- store.getにて予め保存されているID情報やServer情報を取得します。
- child_processにてEXEを実行するための準備をしています。ただ、そのまま実行すると、アプリが起動したままインストーラでインストールする事になり、エラーとなるので、別プロセス起動する必要があります。
- process_envにて、デスクトップのパスを取得させて、既定の保存場所として指定しています。
- exeファイルまでのURLに対して、requestにてHTTP要求を出し、fsにて指定の場所にダウンロードさせています。
- runCommandは、child_processを本体のappとは別プロセスとして起動させる為のルーチンです。
- 別プロセスとして起動させたら、本体のappはapp.quitさせる必要があります。今回は、閉じるとTrayに残る仕様にしていた為、process.exit(0)を呼び出して終了させています。
- require('./package.json').versionにて、package.jsonのversion情報を取得できます。この値と、サーバ側からのバージョン情報を比較して、アップデートの可否を判定させています。
考察
今回の仕組みは
- バージョン問い合わせ
- ファイルのダウンロード
- インストーラの実行
- 自身のプロセスを終了
- インストーラでファイル上書き
といった単純なプロセスです。きちんと、package.jsonのバージョン情報や、サーバ側のバージョン情報をメンテナンスしておく必要があります。完全自動アップデートではありませんが、ほぼ同等のスピードで配布する事が可能です。